@xmanrui/dsh-im 4.19.1 → 4.20.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 (53) hide show
  1. package/README.en.md +96 -4
  2. package/README.md +96 -4
  3. package/lib/client.js +666 -217
  4. package/lib/index.js +277 -273
  5. package/package.json +13 -1
  6. package/plugin-src/client/channels/weixin/api.js +35 -17
  7. package/plugin-src/client/channels/weixin/connection-error.js +68 -0
  8. package/plugin-src/client/channels/weixin/index.js +36 -12
  9. package/plugin-src/client/i18n.js +2 -0
  10. package/plugin-src/client/index.js +15 -0
  11. package/plugin-src/client/interface-language.js +89 -0
  12. package/plugin-src/host/channels/shared/startup.mjs +30 -4
  13. package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
  14. package/plugin-src/host/channels/weixin/index.mjs +12 -3
  15. package/plugin-src/host/channels/weixin/production.mjs +53 -3
  16. package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
  17. package/plugin-src/host/host-language-rpc.mjs +71 -0
  18. package/plugin-src/host/host-language.mjs +157 -0
  19. package/plugin-src/host/index.mjs +15 -2
  20. package/scripts/verify-interface-language.mjs +333 -0
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
  22. package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
  23. package/src/channels/discord/discord-runtime.mjs +1 -1
  24. package/src/channels/feishu/bridge.mjs +44 -13
  25. package/src/channels/qq/qq-bridge.mjs +12 -4
  26. package/src/channels/qq/qq-menu.mjs +11 -8
  27. package/src/channels/shared/bot-workspace-store.mjs +532 -40
  28. package/src/channels/shared/command-catalog.mjs +5 -0
  29. package/src/channels/shared/compact-command.mjs +14 -4
  30. package/src/channels/shared/control-command.mjs +1 -1
  31. package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
  32. package/src/channels/shared/history-command.mjs +1 -1
  33. package/src/channels/shared/i18n-en/discord.mjs +2 -0
  34. package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
  35. package/src/channels/shared/i18n-en/telegram.mjs +5 -0
  36. package/src/channels/shared/i18n-en/weixin.mjs +2 -0
  37. package/src/channels/shared/i18n.mjs +46 -3
  38. package/src/channels/shared/interface-language-store.mjs +127 -0
  39. package/src/channels/shared/interface-language.mjs +51 -0
  40. package/src/channels/shared/model-command.mjs +5 -3
  41. package/src/channels/shared/token-bot-controller.mjs +26 -0
  42. package/src/channels/shared/workspace-command.mjs +114 -9
  43. package/src/channels/shared/workspace-session.mjs +55 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +76 -14
  45. package/src/channels/wecom/wecom-bridge.mjs +2 -2
  46. package/src/channels/weixin/connection-error.en.mjs +116 -0
  47. package/src/channels/weixin/connection-error.mjs +204 -0
  48. package/src/channels/weixin/diagnostic-details.mjs +40 -0
  49. package/src/channels/weixin/state-store.mjs +4 -3
  50. package/src/channels/weixin/weixin-api.mjs +20 -8
  51. package/src/channels/weixin/weixin-bridge.mjs +3 -2
  52. package/src/channels/weixin/weixin-controller.mjs +133 -104
  53. package/src/channels/weixin/weixin-runtime.mjs +35 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.19.1",
3
+ "version": "4.20.0",
4
4
  "description": "把十一种 IM 渠道和公网 AI Office 接入本机 DeepSeek Harness。 Connect eleven IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -52,6 +52,18 @@
52
52
  {
53
53
  "name": "geekyfoxlab",
54
54
  "url": "https://github.com/geekyfoxlab"
55
+ },
56
+ {
57
+ "name": "grloper",
58
+ "url": "https://github.com/grloper"
59
+ },
60
+ {
61
+ "name": "baijian",
62
+ "url": "https://github.com/baijian"
63
+ },
64
+ {
65
+ "name": "lyzhu86",
66
+ "url": "https://github.com/lyzhu86"
55
67
  }
56
68
  ],
57
69
  "license": "MIT",
@@ -1,3 +1,4 @@
1
+ import { normalizeWeixinDiagnosticDetails } from '../../../../src/channels/weixin/diagnostic-details.mjs';
1
2
  import { normalizeBotAlias } from '../../../../src/channels/shared/bot-alias.mjs';
2
3
  import { normalizeAgentPresetCatalog, normalizeAgentPresetId, SET_AGENT_PRESET_ENDPOINT } from '../../agent-preset.js';
3
4
  import { normalizeModelCatalog, normalizeModelSelection, SET_MODEL_ENDPOINT } from '../../model-setting.js';
@@ -57,13 +58,38 @@ function normalizeTestMessage(value) {
57
58
  return { sent: false, code };
58
59
  }
59
60
 
61
+ export function normalizeConnectionError(value, fallbackCode = 'WEIXIN_ERROR', fallbackMessage = '微信操作失败,请稍后重试') {
62
+ const details = normalizeWeixinDiagnosticDetails(value?.details);
63
+ return {
64
+ code: string(value?.code, fallbackCode).slice(0, 100),
65
+ message: string(value?.message, fallbackMessage).slice(0, 500),
66
+ ...(Object.keys(details).length ? { details } : {}),
67
+ };
68
+ }
69
+
70
+ export function managementRequestError(cause, operation) {
71
+ if (cause?.name === 'AbortError') return cause;
72
+ const error = new Error('无法完成微信管理请求,请检查 DSH 连接后重新读取状态。');
73
+ error.code = 'weixin-management-unreachable';
74
+ error.details = normalizeWeixinDiagnosticDetails({ operation, stage: 'management.request', occurredAt: new Date().toISOString() });
75
+ return error;
76
+ }
77
+
78
+ function invalidResponse() {
79
+ const error = new Error('DSH 微信管理接口返回了无法识别的响应,请重新读取状态。');
80
+ error.code = 'weixin-management-invalid-response';
81
+ error.details = { stage: 'management.request', occurredAt: new Date().toISOString() };
82
+ return error;
83
+ }
84
+
60
85
  export function unwrapRpcResult(result) {
61
86
  if (!isRecord(result) || typeof result.ok !== 'boolean') {
62
- throw new Error('微信服务返回了无法识别的响应');
87
+ throw invalidResponse();
63
88
  }
64
89
  if (!result.ok) {
65
- const error = new Error(string(result.error?.message, '微信操作失败'));
66
- error.code = string(result.error?.code, 'WEIXIN_RPC_ERROR');
90
+ if (!isRecord(result.error)) throw invalidResponse();
91
+ const visible = normalizeConnectionError(result.error, 'WEIXIN_RPC_ERROR', '微信操作失败');
92
+ const error = Object.assign(new Error(visible.message), visible);
67
93
  throw error;
68
94
  }
69
95
  return result.value;
@@ -93,7 +119,7 @@ export function safeVerificationUrl(value) {
93
119
 
94
120
  export function normalizeProvisioning(value) {
95
121
  if (!isRecord(value) || !string(value.attemptId)) {
96
- throw new Error('微信扫码服务没有返回有效的绑定任务');
122
+ throw invalidResponse();
97
123
  }
98
124
  const status = PROVISION_STATES.has(value.status) ? value.status : 'failed';
99
125
  const result = {
@@ -110,10 +136,7 @@ export function normalizeProvisioning(value) {
110
136
  if (string(value.botId)) result.botId = string(value.botId);
111
137
  if (value.alreadyConnected === true) result.alreadyConnected = true;
112
138
  if (isRecord(value.error)) {
113
- result.error = {
114
- code: string(value.error.code, 'WEIXIN_PROVISION_FAILED'),
115
- message: string(value.error.message, '微信绑定没有完成'),
116
- };
139
+ result.error = normalizeConnectionError(value.error, 'WEIXIN_PROVISION_FAILED', '微信绑定没有完成');
117
140
  }
118
141
  return result;
119
142
  }
@@ -150,17 +173,14 @@ function normalizeBot(value) {
150
173
  },
151
174
  lastMessageError: normalizeLastMessageError(value.lastMessageError),
152
175
  error: isRecord(value.error)
153
- ? {
154
- code: string(value.error.code, 'WEIXIN_ACCOUNT_ERROR'),
155
- message: string(value.error.message, '微信连接未就绪'),
156
- }
176
+ ? normalizeConnectionError(value.error, 'WEIXIN_ACCOUNT_ERROR', '微信连接未就绪')
157
177
  : null,
158
178
  };
159
179
  }
160
180
 
161
181
  export function normalizeSnapshot(value) {
162
182
  if (!isRecord(value) || !Array.isArray(value.bots)) {
163
- throw new Error('微信服务没有返回有效的账号列表');
183
+ throw invalidResponse();
164
184
  }
165
185
  const bots = value.bots.map(normalizeBot).filter(Boolean);
166
186
  return {
@@ -174,16 +194,14 @@ export function normalizeSnapshot(value) {
174
194
  },
175
195
  provisioning: value.provisioning ? normalizeProvisioning(value.provisioning) : null,
176
196
  testMessage: normalizeTestMessage(value.testMessage),
197
+ warnings: Array.isArray(value.warnings) ? value.warnings.filter(isRecord).slice(0, 8).map(error => normalizeConnectionError(error)) : [],
177
198
  agentPresetCatalog: normalizeAgentPresetCatalog(value.agentPresetCatalog),
178
199
  modelCatalog: normalizeModelCatalog(value.modelCatalog),
179
200
  };
180
201
  }
181
202
 
182
203
  export function presentError(error) {
183
- return {
184
- code: string(error?.code, 'WEIXIN_ERROR'),
185
- message: string(error?.message, '微信操作失败,请稍后重试'),
186
- };
204
+ return normalizeConnectionError(error);
187
205
  }
188
206
 
189
207
  export function formatRemaining(milliseconds) {
@@ -0,0 +1,68 @@
1
+ import * as React from 'react';
2
+ import { h, localizeText } from '../../i18n.js';
3
+ import { normalizeConnectionError } from './api.js';
4
+
5
+ const STAGE_LABELS = {
6
+ 'startup.load': '加载微信配置', 'qr.begin': '申请二维码', 'qr.encode': '生成二维码图片', 'qr.poll': '查询扫码状态',
7
+ 'qr.verify': '提交配对码', 'qr.cancel': '取消绑定', 'credential.read': '读取登录凭据', 'credential.save': '保存登录凭据',
8
+ 'credential.remove': '移除登录凭据', 'account.save': '保存账号配置', 'account.remove': '移除账号配置',
9
+ 'state.load': '读取账号状态', 'state.write': '保存账号状态', 'state.cleanup': '清理账号状态',
10
+ 'workspace.write': '保存工作区设置', 'workspace.cleanup': '清理工作区设置', 'runtime.prepare': '准备消息连接',
11
+ activation: '激活微信账号', 'harness.check': '检查 DSH 宿主', 'connection.start': '启动微信连接',
12
+ 'connection.poll': '同步微信消息', 'connection.stop': '停止微信连接', 'status.read': '读取连接状态',
13
+ rollback: '恢复原账号状态', 'management.request': '访问 DSH 管理接口',
14
+ };
15
+
16
+ export function provisioningErrorTitle(error) {
17
+ const stage = error?.details?.stage;
18
+ if (stage === 'qr.begin' || stage === 'qr.encode') return '无法生成微信二维码';
19
+ if (stage === 'qr.poll') return '查询微信扫码状态失败';
20
+ if (stage === 'management.request') return '无法完成微信管理请求';
21
+ return '微信没有绑定完成';
22
+ }
23
+
24
+ export function formatWeixinDiagnostic(value) {
25
+ const error = normalizeConnectionError(value);
26
+ const details = error.details ?? {};
27
+ return [
28
+ localizeText(error.message), details.hint ? localizeText(details.hint) : null,
29
+ localizeText('错误码') + ': ' + error.code,
30
+ ...['operation', 'stage', 'reason', 'httpStatus', 'providerCode', 'resource', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
31
+ .filter(field => details[field] !== undefined)
32
+ .map(field => `${field}: ${details[field]}`),
33
+ ].filter(Boolean).join('\n');
34
+ }
35
+
36
+ export function WeixinConnectionError({ error: value, warning = false }) {
37
+ const error = normalizeConnectionError(value);
38
+ const details = error.details ?? {};
39
+ const [copyState, setCopyState] = React.useState(null);
40
+ React.useEffect(() => setCopyState(null), [details.referenceId, error.code, error.message]);
41
+ const copy = async () => {
42
+ try {
43
+ if (typeof globalThis.navigator?.clipboard?.writeText !== 'function') throw new Error('clipboard unavailable');
44
+ await globalThis.navigator.clipboard.writeText(formatWeixinDiagnostic(error));
45
+ setCopyState('copied');
46
+ } catch { setCopyState('manual'); }
47
+ };
48
+ const fields = [
49
+ ['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
50
+ ['底层原因', details.reason], ['HTTP 状态', details.httpStatus], ['微信返回码', details.providerCode],
51
+ ['参考号', details.referenceId], ['发生时间', details.occurredAt], ['插件版本', details.pluginVersion],
52
+ ].filter(([, text]) => text !== undefined && text !== '');
53
+ return h('div', { className: 'dxw-summary dim-cardSummary', 'data-weixin-diagnostic': true, role: warning ? 'status' : undefined },
54
+ h('p', null, error.message),
55
+ details.hint ? h('p', null, details.hint) : null,
56
+ h('details', { style: { marginTop: 8 } },
57
+ h('summary', { style: { cursor: 'pointer' } }, '诊断详情'),
58
+ h('dl', { style: { display: 'grid', gridTemplateColumns: 'auto minmax(0, 1fr)', gap: '4px 12px', margin: '10px 0' } },
59
+ ...fields.flatMap(([label, text]) => [h('dt', { key: `${label}-label` }, label),
60
+ React.createElement('dd', { key: label, style: { margin: 0, overflowWrap: 'anywhere' } }, String(text))])),
61
+ !details.referenceId ? h('p', null, '未取得 Host 诊断参考号。') : null,
62
+ h('div', { className: 'dim-viewActions' },
63
+ h('button', { type: 'button', className: 'dxw-button', onClick: copy }, copyState === 'copied' ? '诊断信息已复制' : '复制诊断信息')),
64
+ copyState === 'manual' ? h('div', null,
65
+ h('p', null, '无法访问剪贴板,请选择并复制以下诊断信息。'),
66
+ React.createElement('textarea', { readOnly: true, value: formatWeixinDiagnostic(error), rows: 7,
67
+ 'aria-label': localizeText('诊断信息'), style: { width: '100%', boxSizing: 'border-box', marginTop: 8 } })) : null));
68
+ }
@@ -1,3 +1,4 @@
1
+ import { WeixinConnectionError, provisioningErrorTitle } from './connection-error.js';
1
2
  import { BotName } from '../../bot-alias.js';
2
3
  import * as React from 'react';
3
4
 
@@ -7,6 +8,7 @@ import { CollapsibleAccountSection } from '../shared/collapsible-account.js';
7
8
  import { h } from '../../i18n.js';
8
9
  import {
9
10
  WEIXIN_ENDPOINTS,
11
+ managementRequestError,
10
12
  formatRemaining,
11
13
  normalizeProvisioning,
12
14
  normalizeSnapshot,
@@ -189,9 +191,8 @@ function ProvisionError({ provision, busy, onRetry, onClose }) {
189
191
  const error = provision.error ?? { code: 'WEIXIN_PROVISION_FAILED', message: '微信绑定没有完成' };
190
192
  return h('div', { className: 'dxw-card dim-surfaceCard' },
191
193
  h('div', { className: 'dxw-error dim-inlineError', role: 'alert' },
192
- h('h3', null, provision.status === 'expired' ? '二维码已过期' : '微信没有绑定完成'),
193
- h('p', null, error.message),
194
- h('span', { className: 'dxw-errorCode' }, error.code),
194
+ h('h3', null, provision.status === 'expired' ? '二维码已过期' : provisioningErrorTitle(error)),
195
+ h(WeixinConnectionError, { error }),
195
196
  h('div', { className: 'dxw-actions dim-viewActions' },
196
197
  h(Button, { kind: 'primary', onClick: onRetry, disabled: busy }, '重新生成二维码'),
197
198
  h(Button, { onClick: onClose, disabled: busy }, '关闭'))));
@@ -283,7 +284,8 @@ export function AccountCard({
283
284
  h(Button, { className: 'dim-cardAction', onClick: onReconnect, disabled: Boolean(busy) },
284
285
  busy === 'reconnect' ? '检查中…' : account.connected ? '检查连接' : '重试连接'),
285
286
  h(Button, { className: 'dim-cardAction', kind: 'danger', onClick: onRequestRemove, disabled: Boolean(busy) }, '移除接入')),
286
- summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
287
+ account.error ? h(WeixinConnectionError, { error: account.error })
288
+ : summary ? h('div', { className: 'dxw-summary dim-cardSummary' }, summary) : null,
287
289
  account.lastMessageError ? h(LastMessageErrorSummary, {
288
290
  className: 'dxw-summary',
289
291
  error: account.lastMessageError,
@@ -292,7 +294,7 @@ export function AccountCard({
292
294
  className: 'dxw-summary dim-cardFeedback',
293
295
  role: 'status',
294
296
  'aria-live': 'polite',
295
- }, feedback) : null)),
297
+ }, typeof feedback === 'string' ? feedback : h(WeixinConnectionError, { error: feedback })) : null)),
296
298
  ),
297
299
  ),
298
300
  removing ? h('div', { className: 'dxw-confirm dim-confirm', role: 'alertdialog' },
@@ -358,6 +360,7 @@ export function WeixinSettingsTab({ rpcCall }) {
358
360
  const [feedbackByBot, setFeedbackByBot] = React.useState({});
359
361
  const [removeTarget, setRemoveTarget] = React.useState(null);
360
362
  const [notice, setNotice] = React.useState('');
363
+ const [operationWarnings, setOperationWarnings] = React.useState([]);
361
364
  const [now, setNow] = React.useState(() => Date.now());
362
365
  const addButtonRef = React.useRef(null);
363
366
  const mountedRef = React.useRef(true);
@@ -376,7 +379,13 @@ export function WeixinSettingsTab({ rpcCall }) {
376
379
  }, 'announcement');
377
380
  }, [scheduleAnimationFrame]);
378
381
  const invoke = React.useCallback(async (endpoint, payload = {}, signal) => {
379
- return unwrapRpcResult(await rpcCall(endpoint, payload, signal));
382
+ let result;
383
+ try { result = await rpcCall(endpoint, payload, signal); }
384
+ catch (error) {
385
+ if (signal?.aborted) throw error;
386
+ throw managementRequestError(error, endpoint);
387
+ }
388
+ return unwrapRpcResult(result);
380
389
  }, [rpcCall]);
381
390
  const loadStatus = React.useCallback(async ({
382
391
  signal,
@@ -606,12 +615,12 @@ export function WeixinSettingsTab({ rpcCall }) {
606
615
  setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
607
616
  }
608
617
  announce(feedback);
609
- } catch {
610
- const feedback = '连接检查失败,请稍后重试。';
618
+ } catch (error) {
619
+ const feedback = presentError(error);
611
620
  if (mountedRef.current) {
612
621
  setFeedbackByBot((current) => ({ ...current, [account.botId]: feedback }));
613
622
  }
614
- announce(feedback);
623
+ announce(feedback.message);
615
624
  } finally {
616
625
  const shouldRefresh = workspaceFence.endMutation();
617
626
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -622,6 +631,7 @@ export function WeixinSettingsTab({ rpcCall }) {
622
631
  const saveWorkspace = React.useCallback(async (account, workspace) => {
623
632
  const workspaceVersion = workspaceFence.beginMutation();
624
633
  setBotBusy(account.botId, 'workspace');
634
+ setFeedbackByBot(current => ({ ...current, [account.botId]: null }));
625
635
  try {
626
636
  const snapshot = normalizeSnapshot(await invoke(
627
637
  WEIXIN_ENDPOINTS.setWorkspace,
@@ -635,6 +645,9 @@ export function WeixinSettingsTab({ rpcCall }) {
635
645
  modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
636
646
  });
637
647
  }
648
+ } catch (error) {
649
+ if (mountedRef.current) setFeedbackByBot(current => ({ ...current, [account.botId]: presentError(error) }));
650
+ throw error;
638
651
  } finally {
639
652
  const shouldRefresh = workspaceFence.endMutation();
640
653
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -645,6 +658,7 @@ export function WeixinSettingsTab({ rpcCall }) {
645
658
  const saveBotSetting = React.useCallback(async (account, operation, endpoint, payload) => {
646
659
  const snapshotVersion = workspaceFence.beginMutation();
647
660
  setBotBusy(account.botId, operation);
661
+ setFeedbackByBot(current => ({ ...current, [account.botId]: null }));
648
662
  try {
649
663
  const snapshot = normalizeSnapshot(await invoke(
650
664
  endpoint,
@@ -658,6 +672,9 @@ export function WeixinSettingsTab({ rpcCall }) {
658
672
  modelCatalog: snapshot.modelCatalog ?? EMPTY_MODEL_CATALOG,
659
673
  });
660
674
  }
675
+ } catch (error) {
676
+ if (mountedRef.current) setFeedbackByBot(current => ({ ...current, [account.botId]: presentError(error) }));
677
+ throw error;
661
678
  } finally {
662
679
  const shouldRefresh = workspaceFence.endMutation();
663
680
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -668,6 +685,8 @@ export function WeixinSettingsTab({ rpcCall }) {
668
685
  const remove = React.useCallback(async (account) => {
669
686
  const snapshotVersion = workspaceFence.beginMutation();
670
687
  setBotBusy(account.botId, 'delete');
688
+ setOperationWarnings([]);
689
+ setFeedbackByBot(current => ({ ...current, [account.botId]: null }));
671
690
  try {
672
691
  const snapshot = normalizeSnapshot(await invoke(WEIXIN_ENDPOINTS.deleteBot, {
673
692
  botId: account.botId,
@@ -680,10 +699,13 @@ export function WeixinSettingsTab({ rpcCall }) {
680
699
  modelCatalog: snapshot.modelCatalog ?? current.modelCatalog,
681
700
  }));
682
701
  }
702
+ setOperationWarnings(snapshot.warnings);
683
703
  setRemoveTarget(null);
684
704
  announce('微信账号及本机凭据已移除。');
685
705
  } catch (error) {
686
- announce(`移除失败:${presentError(error).message}`);
706
+ const failure = presentError(error);
707
+ if (mountedRef.current) setFeedbackByBot(current => ({ ...current, [account.botId]: failure }));
708
+ announce(failure.message);
687
709
  } finally {
688
710
  const shouldRefresh = workspaceFence.endMutation();
689
711
  if (shouldRefresh && mountedRef.current) void loadStatus({ silent: true });
@@ -731,8 +753,10 @@ export function WeixinSettingsTab({ rpcCall }) {
731
753
  addButtonRef,
732
754
  }),
733
755
  h('div', { className: 'dxw-visuallyHidden', role: 'status', 'aria-live': 'polite' }, notice),
756
+ ...operationWarnings.map((error, index) => h(WeixinConnectionError, { key: error.details?.referenceId ?? index, error, warning: true })),
734
757
  model.error && model.phase === 'ready'
735
- ? h('div', { className: 'dxw-statusNotice dim-statusNotice' }, `状态刷新失败:${model.error.message}`)
758
+ ? h('div', { className: 'dxw-statusNotice dim-statusNotice' },
759
+ h('p', null, '状态读取失败,以下是上次读取的状态。'), h(WeixinConnectionError, { error: model.error }))
736
760
  : null,
737
761
  model.phase === 'loading'
738
762
  ? h(LoadingView)
@@ -740,7 +764,7 @@ export function WeixinSettingsTab({ rpcCall }) {
740
764
  ? h('div', { className: 'dxw-card dim-surfaceCard' },
741
765
  h('div', { className: 'dxw-error dim-inlineError' },
742
766
  h('h3', null, '无法读取微信状态'),
743
- h('p', null, model.error?.message ?? '请稍后重试'),
767
+ h(WeixinConnectionError, { error: model.error }),
744
768
  h(Button, { onClick: () => void loadStatus() }, '重新读取')))
745
769
  : h(React.Fragment, null,
746
770
  provisionView,
@@ -1,8 +1,10 @@
1
+ import weixinDiagnostics from '../../src/channels/weixin/connection-error.en.mjs';
1
2
  import * as React from 'react';
2
3
 
3
4
  export const IM_LOCALE_NAMESPACE = 'dsh-im';
4
5
 
5
6
  const EN = Object.freeze({
7
+ ...weixinDiagnostics,
6
8
  '$locale': 'en',
7
9
  '修改别名': 'Edit alias',
8
10
  '关闭修改别名': 'Close alias editor',
@@ -51,6 +51,10 @@ import { IMESSAGE_RPC_CHANNEL } from './channels/imessage/api.js';
51
51
  import { IMessageSettingsTab } from './channels/imessage/index.js';
52
52
  import { installIMessageStyles } from './channels/imessage/styles.js';
53
53
  import { en, h, IM_LOCALE_NAMESPACE, setImTranslator, zh } from './i18n.js';
54
+ import {
55
+ HOST_LANGUAGE_RPC_CHANNEL,
56
+ installInterfaceLanguageMirror,
57
+ } from './interface-language.js';
54
58
  import { BotSettingsContext } from './channel-card-meta.js';
55
59
  import {
56
60
  DELIVERY_RPC_CHANNEL,
@@ -398,6 +402,17 @@ export function apply(ctx) {
398
402
  const t = ctx.locale.bind(IM_LOCALE_NAMESPACE);
399
403
  setImTranslator(t);
400
404
 
405
+ // The settings page is the only place that knows the locale the interface is
406
+ // actually rendered in: DSH stores nothing when it came from the browser's
407
+ // language list. Report it so bot messages follow the same language.
408
+ ctx.effect(
409
+ () => installInterfaceLanguageMirror(ctx, {
410
+ rpcCall: (endpoint, payload, signal) =>
411
+ callManagementRpc(ctx.connection, HOST_LANGUAGE_RPC_CHANNEL, endpoint, payload, signal),
412
+ }),
413
+ 'im-settings: mirror the DSH interface language',
414
+ );
415
+
401
416
  ctx.effect(() => installSessionChannelLogos(), 'im-settings: Session channel logos');
402
417
 
403
418
  ctx.effect(() => {
@@ -0,0 +1,89 @@
1
+ import { normalizeInterfaceLanguageTag } from '../../src/channels/shared/interface-language.mjs';
2
+ import { createPollScheduler } from './lifecycle.js';
3
+
4
+ /** Host route serving the interface-language mirror (plugin-src/host/host-language-rpc.mjs). */
5
+ export const HOST_LANGUAGE_RPC_CHANNEL = '/dsh-im-language';
6
+ export const HOST_LANGUAGE_ENDPOINTS = Object.freeze({
7
+ get: 'settings.language.get',
8
+ mirror: 'settings.language.mirror',
9
+ });
10
+
11
+ /** Widening retry delays for a report the Connection could not carry yet. */
12
+ const RETRY_DELAYS_MS = Object.freeze([1_000, 4_000, 15_000]);
13
+
14
+ /**
15
+ * Report the locale the settings UI is actually rendered in to the Host, so
16
+ * bot chat messages and command menus follow the DSH interface language.
17
+ *
18
+ * DSH stores a locale preference only when the reader picks one in the
19
+ * Language row: a locale derived from the browser's language list leaves the
20
+ * Host user-settings document empty. This mirror closes that gap, and the Host
21
+ * keeps an explicit selection ranked above it (see
22
+ * src/channels/shared/interface-language.mjs).
23
+ *
24
+ * The first report runs at plugin load, when the Connection may not be
25
+ * established yet, so a failed report is retried on a widening delay rather
26
+ * than waiting for a locale change that may never come. A failure is both a
27
+ * rejected promise and a resolved `{ ok: false }` envelope — the Host returns
28
+ * the latter when it could not persist the mirror, and that must retry too.
29
+ *
30
+ * @param ctx - client cordis context providing `locale` and the event bus.
31
+ * @param options.rpcCall - management RPC caller for HOST_LANGUAGE_RPC_CHANNEL.
32
+ * @returns an idempotent disposer that stops reporting and cancels any retry.
33
+ */
34
+ export function installInterfaceLanguageMirror(ctx, {
35
+ rpcCall,
36
+ setTimeoutFn = (callback, delayMs) => globalThis.setTimeout(callback, delayMs),
37
+ clearTimeoutFn = (timer) => globalThis.clearTimeout(timer),
38
+ retryDelaysMs = RETRY_DELAYS_MS,
39
+ } = {}) {
40
+ if (typeof rpcCall !== 'function' || typeof ctx?.locale?.getLocale !== 'function') {
41
+ return () => {};
42
+ }
43
+ const delays = retryDelaysMs.length > 0 ? retryDelaysMs : RETRY_DELAYS_MS;
44
+ const scheduler = createPollScheduler({ setTimeoutFn, clearTimeoutFn });
45
+ let mirrored = null;
46
+ let attempt = 0;
47
+
48
+ const retry = (active) => {
49
+ // Leave the tag unmirrored so the retry re-sends it. A Host that cannot
50
+ // persist the mirror still answers in its stored language; there is
51
+ // nothing for the reader to act on here.
52
+ if (mirrored === active) mirrored = null;
53
+ scheduler.schedule(report, delays[Math.min(attempt, delays.length - 1)]);
54
+ attempt += 1;
55
+ };
56
+
57
+ const report = () => {
58
+ if (scheduler.disposed) return;
59
+ const active = normalizeInterfaceLanguageTag(ctx.locale.getLocale()?.active);
60
+ if (active === null || active === mirrored) return;
61
+ mirrored = active;
62
+ Promise.resolve(rpcCall(HOST_LANGUAGE_ENDPOINTS.mirror, { locale: active })).then(
63
+ (result) => {
64
+ // Connection resolves failed business results normally; only a
65
+ // resolved ok envelope proves the Host actually persisted the mirror.
66
+ if (result?.ok === true) {
67
+ attempt = 0;
68
+ return;
69
+ }
70
+ retry(active);
71
+ },
72
+ () => retry(active),
73
+ );
74
+ };
75
+
76
+ report();
77
+ // A deliberate switch is worth reporting immediately, so it restarts the
78
+ // delay ladder instead of inheriting a previous failure's backoff.
79
+ const off = typeof ctx.on === 'function'
80
+ ? ctx.on('locale/change', () => {
81
+ attempt = 0;
82
+ report();
83
+ })
84
+ : null;
85
+ return () => {
86
+ scheduler.dispose();
87
+ if (typeof off === 'function') off();
88
+ };
89
+ }
@@ -1,10 +1,32 @@
1
1
  import { registerManagementRpc } from '../../../management-rpc.mjs';
2
+ import { onImHostLanguageChange } from '../../../../src/channels/shared/i18n.mjs';
2
3
  import { resolveRpcAuthority } from '../../rpc-authority.mjs';
3
4
  import { publicChannelInitializing, publicChannelStartupError } from './startup-error.mjs';
4
5
 
6
+ /**
7
+ * Keep a channel's platform-side command menu in the current host message
8
+ * language. Menus are registered with the platform when a bot connects, so a
9
+ * later language change has to re-send them; channels that publish no menu
10
+ * expose no refresh hook and are left alone.
11
+ */
12
+ function followHostLanguage(ctx, channel, controller, logger) {
13
+ if (typeof controller?.refreshCommandMenus !== 'function') return;
14
+ const unsubscribe = onImHostLanguageChange(() => {
15
+ // Never run a slow or failing platform call inside the language switch:
16
+ // subscribers are synchronous and must not delay or break one another.
17
+ void Promise.resolve()
18
+ .then(() => controller.refreshCommandMenus())
19
+ .catch((error) => logger.warn?.(
20
+ `[dsh-im] failed to re-send the ${channel} command menu after a language change`,
21
+ error,
22
+ ));
23
+ });
24
+ ctx.effect(() => unsubscribe, `dsh-im: follow the host language for ${channel} command menus`);
25
+ }
26
+
5
27
  /** Mount the native management RPC before any fallible production initialization. */
6
28
  export async function installProductionChannel(ctx, config, {
7
- channel, rpcChannel, createProduction, createHandler,
29
+ channel, rpcChannel, createProduction, createHandler, reportStartupError,
8
30
  }) {
9
31
  let startupError = publicChannelInitializing(channel);
10
32
  let handler = async () => ({ ok: false, error: startupError });
@@ -31,14 +53,18 @@ export async function installProductionChannel(ctx, config, {
31
53
  ? config.deliveryService.registerAdapter(production.deliveryAdapter) : undefined;
32
54
  const readyHandler = createHandler(production.controller);
33
55
  ctx.effect(() => closeProduction, `dsh-im: close ${channel} connections`);
56
+ followHostLanguage(ctx, channel, production.controller, logger);
34
57
  handler = readyHandler;
35
58
  } catch (error) {
36
- startupError = publicChannelStartupError(channel, error);
37
- logger.error?.(`[dsh-im] failed to activate ${channel}; management RPC remains available`, error);
59
+ startupError = reportStartupError
60
+ ? reportStartupError(error, false)
61
+ : publicChannelStartupError(channel, error);
62
+ if (!reportStartupError) logger.error?.(`[dsh-im] failed to activate ${channel}; management RPC remains available`, error);
38
63
  try {
39
64
  await closeProduction();
40
65
  } catch (cleanupError) {
41
- logger.error?.(`[dsh-im] failed to close partially initialized ${channel} resources`, cleanupError);
66
+ if (reportStartupError) reportStartupError(cleanupError, true);
67
+ else logger.error?.(`[dsh-im] failed to close partially initialized ${channel} resources`, cleanupError);
42
68
  }
43
69
  }
44
70
  return disposeRpc;
@@ -1,3 +1,5 @@
1
+ import { createWeixinDiagnostics } from '../../../../src/channels/weixin/connection-error.mjs';
2
+
1
3
  const DEFAULT_RETRY_DELAYS_MS = Object.freeze([250, 1_000, 3_000, 5_000, 10_000, 30_000]);
2
4
 
3
5
  function retryDelays(value) {
@@ -10,6 +12,7 @@ export class ConnectionSupervisor {
10
12
  #controller;
11
13
  #harness;
12
14
  #logger;
15
+ #diagnostics;
13
16
  #retryDelays;
14
17
  #healthyIntervalMs;
15
18
  #setTimeout;
@@ -26,6 +29,7 @@ export class ConnectionSupervisor {
26
29
  controller,
27
30
  harness,
28
31
  logger = console,
32
+ diagnostics,
29
33
  retryDelaysMs,
30
34
  healthyIntervalMs = 15_000,
31
35
  setTimeoutImpl = setTimeout,
@@ -40,6 +44,7 @@ export class ConnectionSupervisor {
40
44
  this.#controller = controller;
41
45
  this.#harness = harness;
42
46
  this.#logger = logger;
47
+ this.#diagnostics = diagnostics ?? createWeixinDiagnostics({ logger });
43
48
  this.#retryDelays = retryDelays(retryDelaysMs);
44
49
  this.#healthyIntervalMs = Number.isFinite(healthyIntervalMs) && healthyIntervalMs >= 0
45
50
  ? healthyIntervalMs
@@ -116,7 +121,14 @@ export class ConnectionSupervisor {
116
121
  if (this.#closed) return;
117
122
  const delayMs = this.#retryDelays[Math.min(this.#retryIndex, this.#retryDelays.length - 1)];
118
123
  this.#retryIndex += 1;
119
- this.#logger.warn?.(`[dsh-weixin] connection reconciliation failed; retrying in ${delayMs}ms`, error);
124
+ if (typeof this.#controller.reportRestoreFailure === 'function') {
125
+ try { await this.#controller.reportRestoreFailure(error); }
126
+ catch {
127
+ this.#diagnostics.report(error, { operation: 'connection.restore', stage: 'harness.check', code: 'harness-check-unknown-failed', automatic: true });
128
+ }
129
+ } else {
130
+ this.#diagnostics.report(error, { operation: 'connection.restore', stage: 'harness.check', code: 'harness-check-unknown-failed', automatic: true });
131
+ }
120
132
  this.#schedule(delayMs);
121
133
  }
122
134
  }
@@ -1,20 +1,29 @@
1
1
  import { createProductionController } from './production.mjs';
2
2
  import { createWeixinRpcHandler, installWeixinRpc, WEIXIN_RPC_CHANNEL } from './rpc.mjs';
3
3
  import { installProductionChannel } from '../shared/startup.mjs';
4
+ import { publicChannelStartupError } from '../shared/startup-error.mjs';
5
+ import { createWeixinDiagnostics } from '../../../../src/channels/weixin/connection-error.mjs';
4
6
 
5
7
  export const name = 'dsh-weixin-host';
6
8
  export const inject = ['connection', 'credentials', 'typertGateway'];
7
9
 
8
10
  export async function apply(ctx, config = {}) {
11
+ const logger = typeof ctx.logger === 'function' ? ctx.logger('dsh-weixin') : (ctx.logger ?? console);
12
+ const diagnostics = createWeixinDiagnostics({ logger });
13
+ const rpcOptions = { ...config.rpcOptions, logger, diagnostics };
9
14
  if (config?.controller) {
10
- return installWeixinRpc(ctx, config.controller, config.rpcOptions, config.rpcAuthority);
15
+ return installWeixinRpc(ctx, config.controller, rpcOptions, config.rpcAuthority);
11
16
  }
12
17
 
13
18
  return installProductionChannel(ctx, config, {
14
19
  channel: 'weixin',
15
20
  rpcChannel: WEIXIN_RPC_CHANNEL,
16
- createProduction: () => createProductionController(ctx, config, config.internals),
17
- createHandler: controller => createWeixinRpcHandler(controller, config.rpcOptions),
21
+ createProduction: () => createProductionController(ctx, config, { ...config.internals, diagnostics }),
22
+ createHandler: controller => createWeixinRpcHandler(controller, rpcOptions),
23
+ reportStartupError: (error, warning) => diagnostics.report(error, {
24
+ operation: 'startup', stage: warning ? 'connection.stop' : 'startup.load', warning,
25
+ code: publicChannelStartupError('weixin', error).code,
26
+ }).publicError,
18
27
  });
19
28
  }
20
29