@xmanrui/dsh-im 4.38.1 → 4.38.2
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/lib/client.js +49 -5
- package/lib/index.js +293 -293
- package/package.json +1 -1
- package/plugin-src/client/connection-error.js +12 -2
- package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
- package/plugin-src/host/channels/feishu/production.mjs +1 -1
- package/plugin-src/host/channels/matrix/production.mjs +1 -1
- package/plugin-src/host/channels/qq/production.mjs +1 -1
- package/plugin-src/host/channels/shared/production.mjs +1 -1
- package/plugin-src/host/channels/slack/production.mjs +1 -1
- package/plugin-src/host/channels/wecom/production.mjs +1 -1
- package/plugin-src/host/channels/wecom-app/production.mjs +1 -1
- package/plugin-src/host/channels/weixin/production.mjs +1 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
- package/plugin-src/host/modern-harness-api.mjs +2 -1
- package/src/channels/feishu/bridge.mjs +87 -23
- package/src/channels/shared/bot-workspace-store.mjs +3 -3
- package/src/channels/shared/connection-error.mjs +29 -4
- package/src/channels/shared/diagnostic-details.mjs +6 -2
- package/src/channels/shared/harness-client.mjs +124 -17
- package/src/channels/shared/harness-mux.mjs +6 -4
- package/src/channels/shared/i18n-en/diagnostics.mjs +24 -0
- package/src/channels/shared/message-failure.mjs +30 -10
- package/src/channels/shared/workspace-session.mjs +10 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xmanrui/dsh-im",
|
|
3
|
-
"version": "4.38.
|
|
3
|
+
"version": "4.38.2",
|
|
4
4
|
"icon": "./assets/logo-plugin-message-link-gradient.webp",
|
|
5
5
|
"description": "把微信、飞书、企微、钉钉、QQ、Telegram、WhatsApp、Slack、Discord、Matrix接入DSH。 Connect Telegram, WhatsApp, Slack, Discord, Matrix, WeChat, Feishu, WeCom, DingTalk, and QQ to DSH.",
|
|
6
6
|
"keywords": [
|
|
@@ -4,6 +4,8 @@ import { normalizeDiagnosticDetails } from '../../src/channels/shared/diagnostic
|
|
|
4
4
|
import { CONFIG_ISSUE_LABELS } from '../../src/channels/shared/diagnostic-details.mjs';
|
|
5
5
|
|
|
6
6
|
const STAGE_LABELS = {
|
|
7
|
+
'session.prepare': '准备会话', 'prompt.prepare': '准备消息', 'interaction.open': '建立交互通道',
|
|
8
|
+
'prompt.submit': '提交消息', 'reply.wait': '等待处理结果',
|
|
7
9
|
'credential.verify': '验证凭据', 'permission.check': '检查权限', 'sdk.load': '加载渠道组件',
|
|
8
10
|
operation: '执行渠道操作', 'message.receive': '接收消息', 'message.send': '发送消息', 'connection.test': '发送测试消息',
|
|
9
11
|
'startup.load': '加载渠道配置', 'qr.begin': '申请二维码', 'qr.encode': '生成二维码图片', 'qr.poll': '查询扫码状态',
|
|
@@ -15,6 +17,11 @@ const STAGE_LABELS = {
|
|
|
15
17
|
'connection.poll': '监测连接与消息', 'connection.stop': '停止连接', 'status.read': '读取连接状态',
|
|
16
18
|
rollback: '恢复原账号状态', 'management.request': '访问 DSH 管理接口',
|
|
17
19
|
};
|
|
20
|
+
const SUBMISSION_LABELS = { 'not-submitted': '未提交', accepted: '已确认接收', unknown: '提交结果未知' };
|
|
21
|
+
const STREAM_REASON_LABELS = {
|
|
22
|
+
'stream-closed-before-open': '打开前关闭', 'stream-failed-before-open': '打开前失败',
|
|
23
|
+
'stream-failed': '通道连接失败', unknown: '暂未识别',
|
|
24
|
+
};
|
|
18
25
|
|
|
19
26
|
export function provisioningErrorTitle(error) {
|
|
20
27
|
const stage = error?.details?.stage;
|
|
@@ -30,7 +37,7 @@ export function formatConnectionDiagnostic(value) {
|
|
|
30
37
|
return [
|
|
31
38
|
localizeText(error.message), details.hint ? localizeText(details.hint) : null,
|
|
32
39
|
localizeText('错误码') + ': ' + error.code,
|
|
33
|
-
...['channel', 'operation', 'stage', 'method', 'transport', 'reason', 'reasons', 'host', 'durationMs', 'timeoutMs', 'nodeVersion', 'dshVersion', 'platform', 'dependencies', 'proxyConfigured', 'truncated', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
|
|
40
|
+
...['channel', 'operation', 'stage', 'method', 'transport', 'submissionState', 'attempts', 'lastFailureReason', 'reason', 'reasons', 'host', 'durationMs', 'timeoutMs', 'nodeVersion', 'dshVersion', 'platform', 'dependencies', 'proxyConfigured', 'truncated', 'httpStatus', 'providerCode', 'resource', 'file', 'field', 'issue', 'referenceId', 'occurredAt', 'rollback', 'pluginVersion']
|
|
34
41
|
.filter(field => details[field] !== undefined)
|
|
35
42
|
.map(field => `${field}: ${typeof details[field] === 'object' ? JSON.stringify(details[field]) : details[field]}`),
|
|
36
43
|
].filter(Boolean).join('\n');
|
|
@@ -51,7 +58,10 @@ export function ConnectionError({ error: value, warning = false, showMessage = t
|
|
|
51
58
|
};
|
|
52
59
|
const fields = [
|
|
53
60
|
['错误码', error.code], ['失败阶段', localizeText(STAGE_LABELS[details.stage] ?? details.stage ?? '')],
|
|
54
|
-
['RPC 方法', details.method], ['调用方式', details.transport === 'host-api' ? localizeText('宿主 API(进程内)') : details.transport === 'http' ? 'HTTP' : undefined],
|
|
61
|
+
['RPC 方法', details.method], ['调用方式', details.transport === 'host-api' ? localizeText('宿主 API(进程内)') : details.transport === 'http' ? 'HTTP' : details.transport === 'websocket' ? 'WebSocket' : undefined],
|
|
62
|
+
['消息提交状态', localizeText(SUBMISSION_LABELS[details.submissionState] ?? '')],
|
|
63
|
+
['连接尝试次数', details.attempts],
|
|
64
|
+
['最近一次连接失败', localizeText(STREAM_REASON_LABELS[details.lastFailureReason] ?? details.lastFailureReason ?? '')],
|
|
55
65
|
['底层原因', details.reason === 'unknown' ? localizeText('暂未识别') : details.reason], ['多个原因', details.reasons?.join(', ')], ['服务域名', details.host], ['请求耗时(毫秒)', details.durationMs], ['超时阈值(毫秒)', details.timeoutMs], ['Node 版本', details.nodeVersion], ['DSH 版本', details.dshVersion], ['依赖版本', details.dependencies ? Object.entries(details.dependencies).map(([name, version]) => `${name}: ${version}`).join(', ') : undefined], ['代理已配置', details.proxyConfigured], ['原因链已截断', details.truncated], ['运行平台', details.platform], ['HTTP 状态', details.httpStatus], ['服务返回码', details.providerCode],
|
|
56
66
|
['配置文件', details.file], ['配置字段', details.field],
|
|
57
67
|
['校验原因', details.issue ? localizeText(CONFIG_ISSUE_LABELS[details.issue]) : undefined],
|
|
@@ -116,7 +116,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
116
116
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
117
117
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
118
118
|
});
|
|
119
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
119
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
120
120
|
const coreController = new Controller({
|
|
121
121
|
deviceAuth,
|
|
122
122
|
credentials: ctx.credentials,
|
|
@@ -219,7 +219,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
219
219
|
const proxyEnv = internals.proxyEnv ?? process.env;
|
|
220
220
|
const wsAgent = createFeishuWebSocketAgent(proxyEnv, internals.createProxyAgent);
|
|
221
221
|
|
|
222
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
222
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
223
223
|
const coreController = new Controller({
|
|
224
224
|
logger,
|
|
225
225
|
registerApp: (options) => lark.registerApp(options),
|
|
@@ -127,7 +127,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
127
127
|
...(sessionMaintenanceExecutor ? { sessionMaintenanceExecutor } : {}),
|
|
128
128
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
129
129
|
});
|
|
130
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
130
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
131
131
|
const knownCommandNames = new Set(
|
|
132
132
|
commandsForChannel('matrix').flatMap((entry) => [entry.name, ...entry.aliases.map((alias) => alias.name)]),
|
|
133
133
|
);
|
|
@@ -108,7 +108,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
108
108
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
109
109
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
110
110
|
});
|
|
111
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
111
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
112
112
|
const coreController = new Controller({
|
|
113
113
|
qrAuth,
|
|
114
114
|
credentials: ctx.credentials,
|
|
@@ -126,7 +126,7 @@ export async function createTokenProductionController(ctx, config, internals, de
|
|
|
126
126
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
127
127
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
128
128
|
});
|
|
129
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
129
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
130
130
|
// Assigned just below. A transport may need to write a rotated token back
|
|
131
131
|
// through the controller, which is only constructible after createRuntime is
|
|
132
132
|
// defined, so the reference is held in a slot rather than captured directly.
|
|
@@ -95,7 +95,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
95
95
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
96
96
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
97
97
|
});
|
|
98
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
98
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
99
99
|
const coreController = new ResolvedController({
|
|
100
100
|
credentials: ctx.credentials,
|
|
101
101
|
configStore: observedConfigStore,
|
|
@@ -110,7 +110,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
110
110
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
111
111
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
112
112
|
});
|
|
113
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
113
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
114
114
|
const coreController = new Controller({
|
|
115
115
|
qrAuth,
|
|
116
116
|
credentials: ctx.credentials,
|
|
@@ -144,7 +144,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
144
144
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
145
145
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
146
146
|
});
|
|
147
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
147
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
148
148
|
const coreController = new Controller({
|
|
149
149
|
credentials: ctx.credentials,
|
|
150
150
|
configStore: observedConfigStore,
|
|
@@ -150,7 +150,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
150
150
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
151
151
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
152
152
|
});
|
|
153
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
153
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
154
154
|
const coreController = new Controller({
|
|
155
155
|
api,
|
|
156
156
|
credentials: ctx.credentials,
|
|
@@ -116,7 +116,7 @@ export async function createProductionController(ctx, config = {}, internals = {
|
|
|
116
116
|
...(fileIngressExecutor ? { fileIngressExecutor } : {}),
|
|
117
117
|
imageInputPolicy: () => getImageInputSettingsStore(config).get(),
|
|
118
118
|
});
|
|
119
|
-
const modelCatalog = () => listModelCatalog(harness);
|
|
119
|
+
const modelCatalog = (options) => listModelCatalog(harness, options);
|
|
120
120
|
const coreController = new Controller({
|
|
121
121
|
configStore: observedConfigStore,
|
|
122
122
|
authPath: paths.authPath,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
|
|
3
|
-
import { activeHarnessInteractionOwner, hasActiveHarnessInteractionOwner, harnessToolCallFromEvent, HarnessInteractionError } from '../../src/channels/shared/harness-client.mjs';
|
|
3
|
+
import { activeHarnessInteractionOwner, clearModelCatalogCache, hasActiveHarnessInteractionOwner, harnessToolCallFromEvent, HarnessInteractionError } from '../../src/channels/shared/harness-client.mjs';
|
|
4
4
|
import { extractConnectionEvidence } from '../../src/channels/shared/connection-error.mjs';
|
|
5
5
|
|
|
6
6
|
const modernApis = new WeakMap();
|
|
@@ -857,6 +857,7 @@ class ModernHarnessApi {
|
|
|
857
857
|
dispose() {
|
|
858
858
|
if (this.#disposed) return;
|
|
859
859
|
this.#disposed = true;
|
|
860
|
+
clearModelCatalogCache(this);
|
|
860
861
|
for (const subscription of [...this.#mux]) subscription.close();
|
|
861
862
|
this.#assistantStreams.clear();
|
|
862
863
|
for (const pending of [...this.#pendingApprovals.values()]) pending.settle('cancelled');
|
|
@@ -210,6 +210,19 @@ const STEP_PUSH_POST_CHUNK_MAX_BYTES = 24_000;
|
|
|
210
210
|
const STEP_STREAM_PATCH_MIN_INTERVAL_MS = 1_000;
|
|
211
211
|
/** Confirm missed boundaries from history; elapsed time is never completion. */
|
|
212
212
|
const MIRROR_CHECK_MS = 30_000;
|
|
213
|
+
/** Retire orphan snapshots outside Feishu's message update window. */
|
|
214
|
+
const MIRROR_STALE_MS = 14 * 24 * 60 * 60_000;
|
|
215
|
+
const MIRROR_RECOVERY_MAX_FAILURES = 20;
|
|
216
|
+
|
|
217
|
+
function mirrorErrorText(error) {
|
|
218
|
+
return [error?.response?.data?.msg, error?.msg, error?.message]
|
|
219
|
+
.filter((value) => typeof value === 'string' && value.trim()).join('; ') || String(error);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isExpiredMirrorError(error) {
|
|
223
|
+
return /Message has expired when updating message|Message can only be updated within fourteen days/i
|
|
224
|
+
.test(mirrorErrorText(error));
|
|
225
|
+
}
|
|
213
226
|
/** One answer chunk inside the streaming card: small enough that the block
|
|
214
227
|
* splitter can always distribute blocks across sealed/live cards. */
|
|
215
228
|
const STEP_STREAM_ANSWER_CHUNK_MAX_BYTES = 18_000;
|
|
@@ -849,27 +862,48 @@ export class FeishuHarnessBridge {
|
|
|
849
862
|
}
|
|
850
863
|
|
|
851
864
|
/** Recover only a known finished turn, using its last successful card JSON. */
|
|
852
|
-
async #sealOrphanMirrors() {
|
|
853
|
-
for (const [key,
|
|
854
|
-
if (
|
|
855
|
-
|
|
856
|
-
await this.#queueEventTask(
|
|
865
|
+
async #sealOrphanMirrors(onlyKey = null) {
|
|
866
|
+
for (const [key, queuedEntry] of this.#state.mirrorEntries?.() ?? []) {
|
|
867
|
+
if (onlyKey !== null && key !== onlyKey) continue;
|
|
868
|
+
if (!queuedEntry?.sessionId) continue;
|
|
869
|
+
await this.#queueEventTask(queuedEntry.sessionId, async () => {
|
|
857
870
|
if (this.#signal?.aborted) return;
|
|
858
871
|
// An adopted turn now owns this record and will finish through its queue.
|
|
859
872
|
if (this.#sessionSyncTurns.has(key)) return;
|
|
873
|
+
// A queued timer must not resurrect a deleted record or overwrite a
|
|
874
|
+
// newer snapshot/failure count with the entry captured before queuing.
|
|
875
|
+
let entry = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
876
|
+
if (!entry?.chatId || !entry.sessionId || !Number.isSafeInteger(entry.turn)
|
|
877
|
+
|| !Array.isArray(entry.cardIds) || !entry.cardIds.length) return;
|
|
878
|
+
const now = Date.now();
|
|
879
|
+
if (!Number.isFinite(entry.claimedAt) || entry.claimedAt <= 0 || entry.claimedAt > now) {
|
|
880
|
+
entry = { ...entry, claimedAt: now };
|
|
881
|
+
await this.#state.setMirror?.(key, entry);
|
|
882
|
+
}
|
|
883
|
+
if (now - entry.claimedAt >= MIRROR_STALE_MS) {
|
|
884
|
+
await this.#clearMirrorRecovery(key, 'snapshot expired');
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const failures = Number.isSafeInteger(entry.recoveryFailures) && entry.recoveryFailures > 0
|
|
888
|
+
? entry.recoveryFailures : 0;
|
|
889
|
+
if (failures >= MIRROR_RECOVERY_MAX_FAILURES) {
|
|
890
|
+
await this.#clearMirrorRecovery(key, 'recovery failure limit reached');
|
|
891
|
+
return;
|
|
892
|
+
}
|
|
860
893
|
try {
|
|
861
894
|
const events = await this.#mirrorHistory(entry.sessionId, entry.turn);
|
|
895
|
+
if (this.#signal?.aborted) return;
|
|
862
896
|
const outcome = extractCompletedTurnAnswer(events, { turn: entry.turn });
|
|
863
897
|
if (outcome.endSeq < 0) {
|
|
864
|
-
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors());
|
|
898
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors(key));
|
|
865
899
|
return;
|
|
866
900
|
}
|
|
867
901
|
if (this.#restoreMirrorCard(key, entry)) {
|
|
868
902
|
const result = await this.#finishStepCard(key, {
|
|
869
903
|
answerText: outcome.text, stopped: outcome.reason !== 'completed',
|
|
870
904
|
});
|
|
871
|
-
if (!result?.ok) throw new Error('Recovered mirror could not deliver its final answer');
|
|
872
|
-
await this.#
|
|
905
|
+
if (!result?.ok) throw result?.error ?? new Error('Recovered mirror could not deliver its final answer');
|
|
906
|
+
await this.#clearMirrorRecovery(key);
|
|
873
907
|
return;
|
|
874
908
|
}
|
|
875
909
|
const content = JSON.parse(entry.lastContent);
|
|
@@ -881,15 +915,40 @@ export class FeishuHarnessBridge {
|
|
|
881
915
|
else elements.push({ tag: 'markdown', content: status });
|
|
882
916
|
// Earlier chunks are sealed history; update only the live card.
|
|
883
917
|
await this.#patchStepCard(entry.cardIds.at(-1), JSON.stringify(content));
|
|
884
|
-
await this.#
|
|
918
|
+
await this.#clearMirrorRecovery(key);
|
|
885
919
|
} catch (error) {
|
|
886
|
-
this.#
|
|
887
|
-
this.#
|
|
920
|
+
if (this.#signal?.aborted) return;
|
|
921
|
+
const current = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
922
|
+
if (!current) return;
|
|
923
|
+
if (isExpiredMirrorError(error)) {
|
|
924
|
+
await this.#clearMirrorRecovery(key, 'message expired');
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
await this.#state.setMirror?.(key, { ...current, recoveryFailures: failures + 1 });
|
|
928
|
+
if (failures + 1 >= MIRROR_RECOVERY_MAX_FAILURES) {
|
|
929
|
+
await this.#clearMirrorRecovery(key, 'recovery failure limit reached');
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.#logger.warn?.('[dsh-feishu] mirror recovery failed:', mirrorErrorText(error),
|
|
933
|
+
{ sessionId: entry.sessionId, turn: entry.turn, recoveryFailures: failures + 1 });
|
|
934
|
+
this.#scheduleMirrorCheck(`recovery\0${key}`, () => this.#sealOrphanMirrors(key));
|
|
888
935
|
}
|
|
889
936
|
});
|
|
890
937
|
}
|
|
891
938
|
}
|
|
892
939
|
|
|
940
|
+
async #clearMirrorRecovery(key, reason = null) {
|
|
941
|
+
const timerKey = `recovery\0${key}`;
|
|
942
|
+
clearTimeout(this.#sessionSyncIdleTimers.get(timerKey));
|
|
943
|
+
this.#sessionSyncIdleTimers.delete(timerKey);
|
|
944
|
+
const entry = this.#state.mirrorEntries?.().find(([entryKey]) => entryKey === key)?.[1];
|
|
945
|
+
await this.#state.clearMirror?.(key);
|
|
946
|
+
if (reason && entry) {
|
|
947
|
+
this.#logger.warn?.('[dsh-feishu] mirror recovery stopped:', reason,
|
|
948
|
+
{ sessionId: entry.sessionId, turn: entry.turn, recoveryFailures: entry.recoveryFailures ?? 0 });
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
|
|
893
952
|
setGroupResponseMode(value) {
|
|
894
953
|
this.#groupResponseMode = normalizeFeishuGroupResponseMode(value);
|
|
895
954
|
}
|
|
@@ -4231,10 +4290,12 @@ export class FeishuHarnessBridge {
|
|
|
4231
4290
|
mirror.result = { ok: result?.ok === true, text };
|
|
4232
4291
|
mirror.finishedAt = Date.now();
|
|
4233
4292
|
// Keep the last successful snapshot on failure so recovery can preserve it.
|
|
4234
|
-
if (result?.ok) await this.#
|
|
4235
|
-
else if (
|
|
4293
|
+
if (result?.ok) await this.#clearMirrorRecovery(mirror.key);
|
|
4294
|
+
else if (isExpiredMirrorError(result?.error)) {
|
|
4295
|
+
await this.#clearMirrorRecovery(mirror.key, 'message expired');
|
|
4296
|
+
} else if (recovering) {
|
|
4236
4297
|
this.#sessionSyncTurns.delete(mirror.key);
|
|
4237
|
-
this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors());
|
|
4298
|
+
this.#scheduleMirrorCheck(`recovery\0${mirror.key}`, () => this.#sealOrphanMirrors(mirror.key));
|
|
4238
4299
|
}
|
|
4239
4300
|
return result?.ok === true;
|
|
4240
4301
|
}
|
|
@@ -4924,13 +4985,14 @@ export class FeishuHarnessBridge {
|
|
|
4924
4985
|
card.lastRenderAt = this.#stepPushClock.now();
|
|
4925
4986
|
} catch (error) {
|
|
4926
4987
|
card.broken = true;
|
|
4988
|
+
card.lastError = error;
|
|
4927
4989
|
this.#logger.warn?.(
|
|
4928
4990
|
'[dsh-feishu] step streaming card render failed; the turn continues without it:',
|
|
4929
|
-
|
|
4991
|
+
mirrorErrorText(error),
|
|
4930
4992
|
);
|
|
4931
4993
|
if (card.deliveryViaOpenId) {
|
|
4932
4994
|
this.#logger.warn?.('[dsh-feishu] session-sync mirror card render failed:',
|
|
4933
|
-
|
|
4995
|
+
mirrorErrorText(error),
|
|
4934
4996
|
{ sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn });
|
|
4935
4997
|
}
|
|
4936
4998
|
}
|
|
@@ -4940,9 +5002,8 @@ export class FeishuHarnessBridge {
|
|
|
4940
5002
|
* Seal the turn's card with a terminal status and the final answer. The
|
|
4941
5003
|
* streamed answer draft is rewritten in place with the delivery text; when
|
|
4942
5004
|
* no draft exists the answer is appended. Returns `{ ok, cardIds }` when
|
|
4943
|
-
* the answer is visible
|
|
4944
|
-
*
|
|
4945
|
-
* a successful render). Safe to call twice.
|
|
5005
|
+
* the answer is visible, `{ ok: false, error }` on failure, or null when
|
|
5006
|
+
* no card exists. Callers fall back unless ok is true. Safe to call twice.
|
|
4946
5007
|
*/
|
|
4947
5008
|
async #finishStepCard(key, { stopped = false, answerText = null } = {}) {
|
|
4948
5009
|
const card = this.#stepCards.get(key);
|
|
@@ -4951,7 +5012,7 @@ export class FeishuHarnessBridge {
|
|
|
4951
5012
|
try {
|
|
4952
5013
|
await card.renderChain?.catch?.(() => {});
|
|
4953
5014
|
} catch { /* the seal below decides what the user sees */ }
|
|
4954
|
-
if (card.broken) return
|
|
5015
|
+
if (card.broken) return { ok: false, error: card.lastError };
|
|
4955
5016
|
const status = stopped ? 'stopped' : 'completed';
|
|
4956
5017
|
const body = typeof answerText === 'string' ? answerText : '';
|
|
4957
5018
|
// Whether the answer was already visible from a streamed draft render —
|
|
@@ -4982,10 +5043,10 @@ export class FeishuHarnessBridge {
|
|
|
4982
5043
|
}
|
|
4983
5044
|
this.#logger.warn?.(
|
|
4984
5045
|
'[dsh-feishu] step streaming card finish failed:',
|
|
4985
|
-
|
|
5046
|
+
mirrorErrorText(error),
|
|
4986
5047
|
{ sessionId: card.sessionSyncSessionId, turn: card.sessionSyncTurn },
|
|
4987
5048
|
);
|
|
4988
|
-
return
|
|
5049
|
+
return { ok: false, error };
|
|
4989
5050
|
}
|
|
4990
5051
|
}
|
|
4991
5052
|
|
|
@@ -5040,7 +5101,10 @@ export class FeishuHarnessBridge {
|
|
|
5040
5101
|
data: { content: cardJson },
|
|
5041
5102
|
}));
|
|
5042
5103
|
if (response?.code && response.code !== 0) {
|
|
5043
|
-
|
|
5104
|
+
const error = new Error(`Feishu step card patch failed: ${response.msg || response.code}`);
|
|
5105
|
+
error.code = response.code;
|
|
5106
|
+
error.feishuCode = response.code;
|
|
5107
|
+
throw error;
|
|
5044
5108
|
}
|
|
5045
5109
|
}
|
|
5046
5110
|
|
|
@@ -1469,9 +1469,9 @@ function resolveAgentPresetCatalog(catalog) {
|
|
|
1469
1469
|
: normalizeAgentPresetCatalog(value);
|
|
1470
1470
|
}
|
|
1471
1471
|
|
|
1472
|
-
function resolveModelCatalog(catalog) {
|
|
1472
|
+
function resolveModelCatalog(catalog, options) {
|
|
1473
1473
|
if (!catalog) return null;
|
|
1474
|
-
const value = typeof catalog === 'function' ? catalog() : catalog;
|
|
1474
|
+
const value = typeof catalog === 'function' ? catalog(options) : catalog;
|
|
1475
1475
|
return value && typeof value.then === 'function'
|
|
1476
1476
|
? value.then(normalizeModelCatalog)
|
|
1477
1477
|
: normalizeModelCatalog(value);
|
|
@@ -2264,7 +2264,7 @@ export function createWorkspaceAwareController(controller, {
|
|
|
2264
2264
|
throw error;
|
|
2265
2265
|
}
|
|
2266
2266
|
const catalog = model && modelCatalog
|
|
2267
|
-
? await resolveModelCatalog(modelCatalog)
|
|
2267
|
+
? await resolveModelCatalog(modelCatalog, { fresh: true })
|
|
2268
2268
|
: null;
|
|
2269
2269
|
const entry = modelCatalogEntry(catalog, model);
|
|
2270
2270
|
if (model && (!modelCatalog || !entry)) {
|
|
@@ -6,7 +6,7 @@ import { t } from './i18n.mjs';
|
|
|
6
6
|
const reported = new WeakMap();
|
|
7
7
|
const lastReport = new WeakMap();
|
|
8
8
|
const stages = new WeakMap();
|
|
9
|
-
const OWNED_REASONS = { 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'provider-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
|
|
9
|
+
const OWNED_REASONS = { 'harness-interaction-timeout': 'timeout', 'harness-response-invalid': 'invalid-response', 'connect-timeout': 'timeout', 'telegram-timeout': 'timeout', 'cli-timeout': 'timeout', 'provider-timeout': 'timeout', 'telegram-response-invalid': 'invalid-response', 'office-protocol-mismatch': 'invalid-response' };
|
|
10
10
|
const STANDARD_NAMES = new Set(['Error', 'TypeError', 'SyntaxError', 'AggregateError', 'DOMException', 'AxiosError', 'TimeoutError', 'AbortError']);
|
|
11
11
|
const KNOWN_MESSAGES = {
|
|
12
12
|
'discord-401': 'Discord Bot Token 无效,请重新填写。',
|
|
@@ -54,20 +54,45 @@ export function extractConnectionEvidence(error) {
|
|
|
54
54
|
const allReasons = [...new Set(errors.flatMap(e => e.reason === 'multiple-causes' ? e.reasons ?? [] : [e.reason]).filter(r => r && !['unknown', 'multiple-causes'].includes(r)))];
|
|
55
55
|
const precise = allReasons.filter(r => !['timeout', 'http-error', 'provider-rejected', 'invalid-response'].includes(r));
|
|
56
56
|
const reasons = precise.length ? precise : allReasons;
|
|
57
|
-
const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: e[field] ?? e.details?.[field] })[field]).find(v => v !== undefined);
|
|
57
|
+
const first = field => chain.map(e => normalizeDiagnosticDetails({ [field]: stages.get(e)?.[field] ?? e[field] ?? e.details?.[field] })[field]).find(v => v !== undefined);
|
|
58
|
+
const correlation = {};
|
|
59
|
+
for (const field of ['sessionId', 'promptRpcId']) {
|
|
60
|
+
// Only locally attached identifiers belong in logs, never arbitrary RPC details.
|
|
61
|
+
const value = chain.map(e => stages.get(e)?.[field]).find(v => v !== undefined);
|
|
62
|
+
if (typeof value === 'string' && /^[A-Za-z0-9_-]{1,256}$/u.test(value)) correlation[field] = value;
|
|
63
|
+
}
|
|
58
64
|
return {
|
|
59
|
-
chain, errors,
|
|
65
|
+
chain, errors, correlation,
|
|
60
66
|
details: normalizeDiagnosticDetails({
|
|
61
67
|
reason: reasons.length > 1 ? 'multiple-causes' : reasons[0] ?? 'unknown',
|
|
62
68
|
...(reasons.length > 1 ? { reasons } : {}),
|
|
63
69
|
httpStatus: errors.find(e => e.httpStatus !== undefined)?.httpStatus,
|
|
64
70
|
providerCode: errors.find(e => e.providerCode !== undefined)?.providerCode,
|
|
65
71
|
method: first('method'), transport: first('transport'),
|
|
72
|
+
stage: first('stage'), submissionState: first('submissionState'),
|
|
73
|
+
attempts: first('attempts'), lastFailureReason: first('lastFailureReason'),
|
|
66
74
|
durationMs: first('durationMs'), timeoutMs: first('timeoutMs'), host: first('host'), truncated,
|
|
67
75
|
}),
|
|
68
76
|
};
|
|
69
77
|
}
|
|
70
78
|
|
|
79
|
+
/** Keep request context off shared errors (for example a rejected model-catalog promise).
|
|
80
|
+
* Preserve the original prototype, properties and cause for existing business handlers. */
|
|
81
|
+
export function messageDiagnosticError(cause, context) {
|
|
82
|
+
if (cause?.name === 'AbortError') return cause;
|
|
83
|
+
const source = cause && typeof cause === 'object' ? cause : new Error('Message processing failed');
|
|
84
|
+
const error = source instanceof DOMException
|
|
85
|
+
? Object.defineProperties(new DOMException(source.message, source.name), Object.getOwnPropertyDescriptors(source))
|
|
86
|
+
: Object.defineProperties(Object.setPrototypeOf(source instanceof Error ? new Error() : {}, Object.getPrototypeOf(source)),
|
|
87
|
+
Object.getOwnPropertyDescriptors(source));
|
|
88
|
+
stages.set(error, { ...context, ...stages.get(source) });
|
|
89
|
+
return error;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function runtimeDiagnosticDetails() {
|
|
93
|
+
return { pluginVersion: packageInfo.version, nodeVersion: process.versions.node, platform: process.platform };
|
|
94
|
+
}
|
|
95
|
+
|
|
71
96
|
export async function atConnectionStage(stage, operation, resource) {
|
|
72
97
|
try { return await operation(); }
|
|
73
98
|
catch (error) { throw connectionStageError(error, stage, resource); }
|
|
@@ -145,7 +170,7 @@ export function createConnectionDiagnostics({ channel, logger = console, now = D
|
|
|
145
170
|
const details = normalizeDiagnosticDetails({
|
|
146
171
|
...evidence.details, ...context, ...safe.details, ...described.details, ...owned,
|
|
147
172
|
channel, stage: owned.stage ?? described.details?.stage ?? safe.details?.stage ?? context.stage ?? operationStage(context.operation),
|
|
148
|
-
|
|
173
|
+
...runtimeDiagnosticDetails(),
|
|
149
174
|
});
|
|
150
175
|
const code = safe.code ?? context.code ?? `${channel}-operation-failed`;
|
|
151
176
|
const hint = described.hint ?? safe.hint ?? safe.details?.hint ?? connectionHint(details);
|
|
@@ -7,11 +7,13 @@ const OPERATIONS = new Set([
|
|
|
7
7
|
'bot.workspace.set', 'bot.model.set', 'bot.agent-preset.set', 'bot.context-enhancement.set', 'bot.access-policy.set', 'bot.alias.set',
|
|
8
8
|
]);
|
|
9
9
|
const STAGES = new Set([
|
|
10
|
+
'session.prepare', 'prompt.prepare', 'interaction.open', 'prompt.submit', 'reply.wait',
|
|
10
11
|
'credential.verify', 'permission.check', 'sdk.load', 'operation', 'message.receive', 'message.send', 'connection.test', 'startup.load', 'qr.begin', 'qr.encode', 'qr.poll', 'qr.verify', 'qr.cancel', 'credential.read', 'credential.save', 'credential.remove',
|
|
11
12
|
'account.save', 'account.remove', 'state.load', 'state.write', 'state.cleanup', 'workspace.write', 'workspace.cleanup',
|
|
12
13
|
'runtime.prepare', 'activation', 'harness.check', 'connection.start', 'connection.poll', 'connection.stop', 'status.read', 'rollback', 'management.request',
|
|
13
14
|
]);
|
|
14
15
|
const REASONS = new Set([
|
|
16
|
+
'stream-closed-before-open', 'stream-failed-before-open', 'stream-failed',
|
|
15
17
|
'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH', 'ENETUNREACH', 'ETIMEDOUT', 'EPIPE',
|
|
16
18
|
'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT', 'UND_ERR_BODY_TIMEOUT', 'UND_ERR_SOCKET',
|
|
17
19
|
'CERT_HAS_EXPIRED', 'CERT_NOT_YET_VALID', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN',
|
|
@@ -25,7 +27,8 @@ const RPC_METHODS = new Set([
|
|
|
25
27
|
'session.history', 'session.prompt', 'session.rename', 'session.cancel', 'session.models',
|
|
26
28
|
'session.permissions', 'session.selectModel', 'llm.models',
|
|
27
29
|
]);
|
|
28
|
-
const TRANSPORTS = new Set(['host-api', 'http']);
|
|
30
|
+
const TRANSPORTS = new Set(['host-api', 'http', 'websocket']);
|
|
31
|
+
const SUBMISSION_STATES = new Set(['not-submitted', 'accepted', 'unknown']);
|
|
29
32
|
const CONFIG_FILES = new Set(['config.json', 'workspaces.json']);
|
|
30
33
|
export const CONFIG_ISSUE_LABELS = Object.freeze({
|
|
31
34
|
'expected-object': '应为 JSON 对象。',
|
|
@@ -50,7 +53,7 @@ const CONFIG_FIELD = /^(?:\$|version|accounts(?:\[\d{1,10}\](?:\.(?:accountId|ow
|
|
|
50
53
|
export function normalizeDiagnosticDetails(value) {
|
|
51
54
|
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
|
|
52
55
|
const result = {};
|
|
53
|
-
for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES], ['method', RPC_METHODS], ['transport', TRANSPORTS]]) {
|
|
56
|
+
for (const [field, allowed] of [['operation', OPERATIONS], ['stage', STAGES], ['reason', REASONS], ['resource', RESOURCES], ['method', RPC_METHODS], ['transport', TRANSPORTS], ['submissionState', SUBMISSION_STATES], ['lastFailureReason', REASONS]]) {
|
|
54
57
|
if (allowed.has(value[field])) result[field] = value[field];
|
|
55
58
|
}
|
|
56
59
|
if (DIAGNOSTIC_CHANNELS.includes(value.channel)) result.channel = value.channel;
|
|
@@ -59,6 +62,7 @@ export function normalizeDiagnosticDetails(value) {
|
|
|
59
62
|
if (reasons.length) result.reasons = reasons;
|
|
60
63
|
}
|
|
61
64
|
for (const field of ['durationMs', 'timeoutMs']) if (Number.isFinite(value[field]) && value[field] >= 0) result[field] = Math.round(value[field]);
|
|
65
|
+
if (Number.isSafeInteger(value.attempts) && value.attempts >= 0) result.attempts = value.attempts;
|
|
62
66
|
for (const field of ['nodeVersion', 'dshVersion']) if (typeof value[field] === 'string' && /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(value[field]) && value[field].length < 80) result[field] = value[field];
|
|
63
67
|
if (['darwin', 'linux', 'win32', 'freebsd'].includes(value.platform)) result.platform = value.platform;
|
|
64
68
|
if (['ilinkai.weixin.qq.com', 'oapi.dingtalk.com', 'api.dingtalk.com', 'api.telegram.org', 'discord.com', 'slack.com', 'open.feishu.cn', 'qyapi.weixin.qq.com'].includes(value.host)) result.host = value.host;
|