@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.
- package/README.en.md +96 -4
- package/README.md +96 -4
- package/lib/client.js +666 -217
- package/lib/index.js +277 -273
- package/package.json +13 -1
- package/plugin-src/client/channels/weixin/api.js +35 -17
- package/plugin-src/client/channels/weixin/connection-error.js +68 -0
- package/plugin-src/client/channels/weixin/index.js +36 -12
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/index.js +15 -0
- package/plugin-src/client/interface-language.js +89 -0
- package/plugin-src/host/channels/shared/startup.mjs +30 -4
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
- package/plugin-src/host/channels/weixin/index.mjs +12 -3
- package/plugin-src/host/channels/weixin/production.mjs +53 -3
- package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
- package/plugin-src/host/host-language-rpc.mjs +71 -0
- package/plugin-src/host/host-language.mjs +157 -0
- package/plugin-src/host/index.mjs +15 -2
- package/scripts/verify-interface-language.mjs +333 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
- package/src/channels/discord/discord-runtime.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +44 -13
- package/src/channels/qq/qq-bridge.mjs +12 -4
- package/src/channels/qq/qq-menu.mjs +11 -8
- package/src/channels/shared/bot-workspace-store.mjs +532 -40
- package/src/channels/shared/command-catalog.mjs +5 -0
- package/src/channels/shared/compact-command.mjs +14 -4
- package/src/channels/shared/control-command.mjs +1 -1
- package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
- package/src/channels/shared/history-command.mjs +1 -1
- package/src/channels/shared/i18n-en/discord.mjs +2 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
- package/src/channels/shared/i18n-en/telegram.mjs +5 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/i18n.mjs +46 -3
- package/src/channels/shared/interface-language-store.mjs +127 -0
- package/src/channels/shared/interface-language.mjs +51 -0
- package/src/channels/shared/model-command.mjs +5 -3
- package/src/channels/shared/token-bot-controller.mjs +26 -0
- package/src/channels/shared/workspace-command.mjs +114 -9
- package/src/channels/shared/workspace-session.mjs +55 -5
- package/src/channels/telegram/telegram-runtime.mjs +76 -14
- package/src/channels/wecom/wecom-bridge.mjs +2 -2
- package/src/channels/weixin/connection-error.en.mjs +116 -0
- package/src/channels/weixin/connection-error.mjs +204 -0
- package/src/channels/weixin/diagnostic-details.mjs +40 -0
- package/src/channels/weixin/state-store.mjs +4 -3
- package/src/channels/weixin/weixin-api.mjs +20 -8
- package/src/channels/weixin/weixin-bridge.mjs +3 -2
- package/src/channels/weixin/weixin-controller.mjs +133 -104
- 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.
|
|
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
|
|
87
|
+
throw invalidResponse();
|
|
63
88
|
}
|
|
64
89
|
if (!result.ok) {
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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' },
|
|
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(
|
|
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 =
|
|
37
|
-
|
|
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
|
-
|
|
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.#
|
|
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,
|
|
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,
|
|
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
|
|