@xmanrui/dsh-im 4.21.2 → 4.23.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 +10 -5
- package/README.md +10 -5
- package/docs/client-integration.md +81 -0
- package/lib/client.js +2565 -1134
- package/lib/index.js +292 -301
- package/package.json +9 -1
- package/plugin-src/client/channel-card-meta.js +2 -0
- package/plugin-src/client/channel-logos.js +11 -0
- package/plugin-src/client/channels/dingtalk/api.js +5 -1
- package/plugin-src/client/channels/dingtalk/index.js +15 -13
- package/plugin-src/client/channels/email/api.js +52 -0
- package/plugin-src/client/channels/email/index.js +728 -0
- package/plugin-src/client/channels/email/styles.js +51 -0
- package/plugin-src/client/channels/feishu/api.js +4 -2
- package/plugin-src/client/channels/feishu/index.js +16 -3
- package/plugin-src/client/channels/imessage/index.js +2 -1
- package/plugin-src/client/channels/office/api.js +2 -0
- package/plugin-src/client/channels/office/index.js +6 -5
- package/plugin-src/client/channels/qq/api.js +7 -0
- package/plugin-src/client/channels/qq/index.js +16 -3
- package/plugin-src/client/channels/shared/token-api.js +5 -1
- package/plugin-src/client/channels/shared/token-channel.js +28 -3
- package/plugin-src/client/channels/slack/index.js +2 -1
- package/plugin-src/client/channels/wecom/api.js +7 -1
- package/plugin-src/client/channels/wecom/index.js +16 -3
- package/plugin-src/client/channels/wecom-app/api.js +6 -1
- package/plugin-src/client/channels/wecom-app/index.js +16 -3
- package/plugin-src/client/channels/weixin/api.js +2 -1
- package/plugin-src/client/channels/weixin/connection-error.js +1 -71
- package/plugin-src/client/channels/whatsapp/api.js +5 -0
- package/plugin-src/client/channels/whatsapp/index.js +16 -3
- package/plugin-src/client/connection-error.js +87 -0
- package/plugin-src/client/global-settings.js +81 -1
- package/plugin-src/client/i18n.js +62 -0
- package/plugin-src/client/index.js +122 -28
- package/plugin-src/client/last-message-error.js +2 -1
- package/plugin-src/client/panel-error-boundary.js +21 -0
- package/plugin-src/client/styles.js +6 -0
- package/plugin-src/host/build.mjs +4 -0
- package/plugin-src/host/channels/dingtalk/production.mjs +5 -1
- package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
- package/plugin-src/host/channels/email/availability.mjs +60 -0
- package/plugin-src/host/channels/email/index.mjs +36 -0
- package/plugin-src/host/channels/email/production.mjs +58 -0
- package/plugin-src/host/channels/email/rpc.mjs +163 -0
- package/plugin-src/host/channels/feishu/production.mjs +6 -1
- package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
- package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
- package/plugin-src/host/channels/office/rpc.mjs +3 -1
- package/plugin-src/host/channels/qq/production.mjs +5 -1
- package/plugin-src/host/channels/qq/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/production.mjs +55 -6
- package/plugin-src/host/channels/shared/rpc.mjs +5 -3
- package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
- package/plugin-src/host/channels/shared/startup.mjs +11 -6
- package/plugin-src/host/channels/slack/production.mjs +5 -1
- package/plugin-src/host/channels/slack/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom/production.mjs +5 -1
- package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
- package/plugin-src/host/channels/wecom-app/production.mjs +5 -1
- package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
- package/plugin-src/host/channels/weixin/production.mjs +5 -1
- package/plugin-src/host/channels/whatsapp/production.mjs +5 -1
- package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
- package/plugin-src/host/delivery-adapter.mjs +11 -0
- package/plugin-src/host/image-input-rpc.mjs +24 -0
- package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
- package/plugin-src/host/index.mjs +3 -0
- package/plugin-src/management-rpc.mjs +12 -2
- package/scripts/verify-package.mjs +3 -1
- package/src/channels/dingtalk/connection-error.mjs +5 -12
- package/src/channels/dingtalk/device-auth.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
- package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
- package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
- package/src/channels/discord/discord-api.mjs +3 -3
- package/src/channels/discord/discord-runtime.mjs +18 -12
- package/src/channels/email/config-store.mjs +194 -0
- package/src/channels/email/email-api.mjs +20 -0
- package/src/channels/email/email-bridge.mjs +17 -0
- package/src/channels/email/email-controller.mjs +824 -0
- package/src/channels/email/email-runtime.mjs +692 -0
- package/src/channels/email/harness-client.mjs +7 -0
- package/src/channels/email/mail-format.mjs +91 -0
- package/src/channels/email/state-store.mjs +176 -0
- package/src/channels/email/transport.mjs +64 -0
- package/src/channels/email/transports/agent-mail.mjs +593 -0
- package/src/channels/email/transports/agently-cli.mjs +288 -0
- package/src/channels/email/transports/imap-smtp.mjs +165 -0
- package/src/channels/feishu/bridge.mjs +8 -7
- package/src/channels/feishu/feishu-runtime.mjs +15 -8
- package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
- package/src/channels/feishu/message-utils.mjs +3 -2
- package/src/channels/feishu/multi-bot-controller.mjs +56 -47
- package/src/channels/feishu/registration-manager.mjs +8 -4
- package/src/channels/feishu/repair-manager.mjs +2 -1
- package/src/channels/imessage/runtime.mjs +9 -5
- package/src/channels/office/office-controller.mjs +23 -12
- package/src/channels/office/office-runtime.mjs +6 -3
- package/src/channels/office/office-transport.mjs +1 -0
- package/src/channels/qq/qq-bridge.mjs +7 -5
- package/src/channels/qq/qq-controller.mjs +56 -42
- package/src/channels/qq/qq-runtime.mjs +16 -11
- package/src/channels/shared/bot-workspace-store.mjs +37 -21
- package/src/channels/shared/connection-error.mjs +191 -0
- package/src/channels/shared/connection-test.mjs +3 -1
- package/src/channels/shared/conversation-state-store.mjs +46 -1
- package/src/channels/shared/default-workspace.mjs +28 -0
- package/src/channels/shared/diagnostic-details.mjs +95 -0
- package/src/channels/shared/harness-client.mjs +111 -20
- package/src/channels/shared/harness-session-binding.mjs +13 -8
- package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
- package/src/channels/shared/i18n-en/email.mjs +25 -0
- package/src/channels/shared/i18n-en/image-input.mjs +17 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
- package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
- package/src/channels/shared/i18n-en.mjs +6 -0
- package/src/channels/shared/image-input-policy.mjs +35 -0
- package/src/channels/shared/image-input-settings-store.mjs +60 -0
- package/src/channels/shared/image-input.mjs +124 -0
- package/src/channels/shared/image-prompt.mjs +15 -7
- package/src/channels/shared/message-failure.mjs +12 -0
- package/src/channels/shared/semantic/reply-reference.mjs +3 -3
- package/src/channels/shared/text-harness-bridge.mjs +90 -11
- package/src/channels/shared/token-bot-controller.mjs +52 -47
- package/src/channels/slack/slack-api.mjs +3 -3
- package/src/channels/slack/slack-controller.mjs +59 -51
- package/src/channels/slack/slack-runtime.mjs +18 -12
- package/src/channels/telegram/telegram-api.mjs +2 -2
- package/src/channels/telegram/telegram-runtime.mjs +17 -13
- package/src/channels/wecom/wecom-bridge.mjs +15 -6
- package/src/channels/wecom/wecom-controller.mjs +51 -37
- package/src/channels/wecom/wecom-runtime.mjs +10 -6
- package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
- package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
- package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
- package/src/channels/weixin/connection-error.mjs +26 -92
- package/src/channels/weixin/diagnostic-details.mjs +1 -63
- package/src/channels/weixin/weixin-bridge.mjs +7 -5
- package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
- package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
detectedImageMediaType, ImagePromptError, imageStorageName, loadedImage, imageDownloadLimitMessage,
|
|
6
|
+
} from './image-prompt.mjs';
|
|
7
|
+
import { t } from './i18n.mjs';
|
|
8
|
+
|
|
9
|
+
const MAX_INPUT_PIXELS = 64_000_000;
|
|
10
|
+
const COMPRESSION_STEPS = [[2048, 80], [1536, 70], [1024, 60]];
|
|
11
|
+
let sharpPromise;
|
|
12
|
+
async function loadSharp() {
|
|
13
|
+
sharpPromise ??= import('sharp').then((module) => module.default).catch(() => null);
|
|
14
|
+
return sharpPromise;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const IMAGE_ORIGINALS_PROMPT = '图片原文件已保存到工作区,文件清单中的图片序号与本条消息一致。图片内容块可能经过缩放或压缩;需要查看细节时可用工具读取原文件。';
|
|
18
|
+
export const IMAGE_INPUT_FALLBACK_PROMPT = '部分图片未直接提供给模型,图片序号:{indexes}。原文件已保存,请使用可用工具读取或分析;不要假设自己已看到这些图片。';
|
|
19
|
+
export const IMAGE_HOST_LIMIT_FALLBACK_PROMPT = '宿主拒绝了本次图片输入。原图已保存到工作区,请根据文件清单使用可用工具读取或分析,不要假设自己已看到图片。';
|
|
20
|
+
|
|
21
|
+
/** Keep provider loaders intact; stage and validate each original once. */
|
|
22
|
+
export function inboundImagesAsFiles(images, limits) {
|
|
23
|
+
if (images.length > limits.maxImages) {
|
|
24
|
+
throw new ImagePromptError('too-many-images', 'Too many inbound images',
|
|
25
|
+
t('一次最多只能处理 {maxImages} 张图片。', { maxImages: limits.maxImages }));
|
|
26
|
+
}
|
|
27
|
+
const tooLarge = () => new ImagePromptError('image-too-large',
|
|
28
|
+
`Image exceeds ${limits.maxDownloadBytes} bytes`, imageDownloadLimitMessage(limits.maxDownloadBytes));
|
|
29
|
+
// Check declared sizes before any download starts, including later images.
|
|
30
|
+
for (const source of images) {
|
|
31
|
+
if (Number.isFinite(source?.size) && source.size > limits.maxDownloadBytes) throw tooLarge();
|
|
32
|
+
}
|
|
33
|
+
return images.map((source, index) => ({
|
|
34
|
+
async load({ signal } = {}) {
|
|
35
|
+
signal?.throwIfAborted();
|
|
36
|
+
let value;
|
|
37
|
+
try {
|
|
38
|
+
value = source.data === undefined
|
|
39
|
+
? await source.load?.({ signal, maxBytes: limits.maxDownloadBytes }) : source.data;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (signal?.aborted) throw signal.reason ?? error;
|
|
42
|
+
if (error?.code === 'image-too-large') throw tooLarge();
|
|
43
|
+
if (error instanceof ImagePromptError || ['AbortError', 'TimeoutError'].includes(error?.name)) throw error;
|
|
44
|
+
throw new ImagePromptError('image-download-failed', 'Unable to download image',
|
|
45
|
+
t('图片下载失败,请重新发送后再试。'), { cause: error });
|
|
46
|
+
}
|
|
47
|
+
signal?.throwIfAborted();
|
|
48
|
+
const loaded = loadedImage(value);
|
|
49
|
+
if (!loaded?.data.length) throw new ImagePromptError('invalid-image-data', 'Empty image',
|
|
50
|
+
t('未能读取图片内容,请重新发送。'));
|
|
51
|
+
if (loaded.data.length > limits.maxDownloadBytes) throw tooLarge();
|
|
52
|
+
const mediaType = detectedImageMediaType(loaded.data);
|
|
53
|
+
if (!mediaType) throw new ImagePromptError('unsupported-image-type', 'Unsupported image',
|
|
54
|
+
t('暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。'));
|
|
55
|
+
return { data: loaded.data, mediaType,
|
|
56
|
+
name: `${index + 1}-${imageStorageName(loaded.name ?? source.name, mediaType, index)}` };
|
|
57
|
+
},
|
|
58
|
+
}));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A missing optional codec still permits small native images and file delivery. */
|
|
62
|
+
export async function modelImageFromOriginal(data, mediaType, maxBytes, {
|
|
63
|
+
signal, sharpLoader = loadSharp,
|
|
64
|
+
} = {}) {
|
|
65
|
+
signal?.throwIfAborted();
|
|
66
|
+
if (maxBytes <= 0) return null;
|
|
67
|
+
const sharp = await sharpLoader();
|
|
68
|
+
signal?.throwIfAborted();
|
|
69
|
+
if (!sharp) return data.length <= maxBytes ? { data, mediaType } : null;
|
|
70
|
+
try {
|
|
71
|
+
const metadata = await sharp(data, { limitInputPixels: MAX_INPUT_PIXELS }).metadata();
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
if (metadata.width * metadata.height > MAX_INPUT_PIXELS) return null;
|
|
74
|
+
if ((metadata.pages ?? 1) > 1 || mediaType === 'image/gif') {
|
|
75
|
+
return data.length <= maxBytes ? { data, mediaType } : null;
|
|
76
|
+
}
|
|
77
|
+
if (data.length <= maxBytes && metadata.width <= 2048 && metadata.height <= 2048) {
|
|
78
|
+
return { data, mediaType };
|
|
79
|
+
}
|
|
80
|
+
for (const [side, quality] of COMPRESSION_STEPS) {
|
|
81
|
+
signal?.throwIfAborted();
|
|
82
|
+
const operation = sharp(data, { limitInputPixels: MAX_INPUT_PIXELS }).rotate()
|
|
83
|
+
.resize({ width: side, height: side, fit: 'inside', withoutEnlargement: true });
|
|
84
|
+
const output = metadata.hasAlpha
|
|
85
|
+
? await operation.png({ compressionLevel: 9 }).toBuffer()
|
|
86
|
+
: await operation.jpeg({ quality }).toBuffer();
|
|
87
|
+
signal?.throwIfAborted();
|
|
88
|
+
if (output.length <= maxBytes) return { data: output,
|
|
89
|
+
mediaType: metadata.hasAlpha ? 'image/png' : 'image/jpeg' };
|
|
90
|
+
}
|
|
91
|
+
} catch (error) {
|
|
92
|
+
signal?.throwIfAborted();
|
|
93
|
+
// A codec failure cannot discard an original already saved for tools.
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function imageContentFromStaged(staged, limits, { signal, sharpLoader } = {}) {
|
|
99
|
+
const content = [];
|
|
100
|
+
const fileOnly = [];
|
|
101
|
+
let remaining = limits.maxTotalImageBytes;
|
|
102
|
+
for (const [index, file] of staged.files.entries()) {
|
|
103
|
+
signal?.throwIfAborted();
|
|
104
|
+
// Staging owns both the directory and basename; never read a user-supplied path.
|
|
105
|
+
const data = await readFile(join(staged.directory, basename(file.path)), { signal });
|
|
106
|
+
const result = await modelImageFromOriginal(data, file.mediaType,
|
|
107
|
+
Math.min(limits.maxImageBytes, remaining), { signal, sharpLoader });
|
|
108
|
+
if (!result) { fileOnly.push(index + 1); continue; }
|
|
109
|
+
remaining -= result.data.length;
|
|
110
|
+
content.push({ type: 'text', text: t('图片 {index}(原图文件:{name})', { index: index + 1, name: file.name }) },
|
|
111
|
+
{ type: 'image', mediaType: result.mediaType, data: result.data.toString('base64'),
|
|
112
|
+
name: imageStorageName(file.name.replace(/\.[^.]+$/, ''), result.mediaType, index) });
|
|
113
|
+
}
|
|
114
|
+
content.push({ type: 'text', text: t(IMAGE_ORIGINALS_PROMPT) });
|
|
115
|
+
if (fileOnly.length) content.push({ type: 'text',
|
|
116
|
+
text: t(IMAGE_INPUT_FALLBACK_PROMPT, { indexes: fileOnly.join(', ') }) });
|
|
117
|
+
return content;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function isImageAdmissionRejection(error) {
|
|
121
|
+
return ['attachment-error', 'session/attachment-invalid'].includes(error?.code)
|
|
122
|
+
&& ['MODEL_DOES_NOT_SUPPORT_IMAGES', 'IMAGE_TOO_LARGE', 'IMAGES_TOO_LARGE',
|
|
123
|
+
'IMAGE_TOO_MANY_PIXELS', 'TOO_MANY_IMAGES'].includes(error?.details?.reason);
|
|
124
|
+
}
|
|
@@ -12,6 +12,11 @@ export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
|
|
|
12
12
|
*/
|
|
13
13
|
export const IMAGE_FILE_FALLBACK_PROMPT = '当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方文件清单)。请使用可用工具分析这些图片文件后回答,例如 run_code 或 pwsh 读取字节、解析元数据、调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。';
|
|
14
14
|
|
|
15
|
+
export function imageDownloadLimitMessage(maxBytes) {
|
|
16
|
+
return t('图片超过原图接收上限 {maxMb} MB,请压缩后重试,或在通用设置的附件中调整上限。',
|
|
17
|
+
{ maxMb: Math.round(maxBytes / (1024 * 1024) * 100) / 100 });
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
export class ImagePromptError extends Error {
|
|
16
21
|
constructor(code, message, userMessage, options = {}) {
|
|
17
22
|
super(message, options);
|
|
@@ -92,7 +97,7 @@ export async function fetchImageBuffer(url, {
|
|
|
92
97
|
throw new ImagePromptError(
|
|
93
98
|
'image-too-large',
|
|
94
99
|
`Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`,
|
|
95
|
-
|
|
100
|
+
imageDownloadLimitMessage(maxBytes),
|
|
96
101
|
);
|
|
97
102
|
}
|
|
98
103
|
|
|
@@ -107,7 +112,7 @@ export async function fetchImageBuffer(url, {
|
|
|
107
112
|
throw new ImagePromptError(
|
|
108
113
|
'image-too-large',
|
|
109
114
|
`Image response exceeded ${maxBytes} bytes`,
|
|
110
|
-
|
|
115
|
+
imageDownloadLimitMessage(maxBytes),
|
|
111
116
|
);
|
|
112
117
|
}
|
|
113
118
|
chunks.push(data);
|
|
@@ -120,7 +125,7 @@ export async function fetchImageBuffer(url, {
|
|
|
120
125
|
throw new ImagePromptError(
|
|
121
126
|
'image-too-large',
|
|
122
127
|
`Image response contains ${data.length} bytes; the limit is ${maxBytes}`,
|
|
123
|
-
|
|
128
|
+
imageDownloadLimitMessage(maxBytes),
|
|
124
129
|
);
|
|
125
130
|
}
|
|
126
131
|
return data;
|
|
@@ -146,7 +151,7 @@ function safeName(value) {
|
|
|
146
151
|
return name || undefined;
|
|
147
152
|
}
|
|
148
153
|
|
|
149
|
-
function detectedImageMediaType(data) {
|
|
154
|
+
export function detectedImageMediaType(data) {
|
|
150
155
|
if (data.length >= 8
|
|
151
156
|
&& data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
|
|
152
157
|
&& data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) {
|
|
@@ -167,7 +172,7 @@ function detectedImageMediaType(data) {
|
|
|
167
172
|
return null;
|
|
168
173
|
}
|
|
169
174
|
|
|
170
|
-
function loadedImage(value) {
|
|
175
|
+
export function loadedImage(value) {
|
|
171
176
|
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
|
|
172
177
|
return { data: Buffer.from(value) };
|
|
173
178
|
}
|
|
@@ -191,12 +196,13 @@ export function hasInboundPrompt(message) {
|
|
|
191
196
|
|
|
192
197
|
export async function promptContentForMessage(message, {
|
|
193
198
|
signal,
|
|
199
|
+
deferImages = false,
|
|
194
200
|
maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
|
|
195
201
|
maxImages = DEFAULT_MAX_IMAGES,
|
|
196
202
|
maxTotalImageBytes = DEFAULT_MAX_TOTAL_IMAGE_BYTES,
|
|
197
203
|
} = {}) {
|
|
198
204
|
const sources = imageSources(message);
|
|
199
|
-
if (sources.length > maxImages) {
|
|
205
|
+
if (!deferImages && sources.length > maxImages) {
|
|
200
206
|
throw new ImagePromptError(
|
|
201
207
|
'too-many-images',
|
|
202
208
|
`Image message contains ${sources.length} images; the limit is ${maxImages}`,
|
|
@@ -210,6 +216,8 @@ export async function promptContentForMessage(message, {
|
|
|
210
216
|
if (text) content.push({ type: 'text', text });
|
|
211
217
|
else if (sources.length > 0) content.push({ type: 'text', text: t(DEFAULT_IMAGE_PROMPT) });
|
|
212
218
|
|
|
219
|
+
if (deferImages) return content;
|
|
220
|
+
|
|
213
221
|
for (const [index, source] of sources.entries()) {
|
|
214
222
|
signal?.throwIfAborted();
|
|
215
223
|
if (Number.isFinite(source?.size) && source.size > maxImageBytes) {
|
|
@@ -315,7 +323,7 @@ const IMAGE_FILE_EXTENSIONS = new Map([
|
|
|
315
323
|
|
|
316
324
|
const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
|
|
317
325
|
|
|
318
|
-
function imageStorageName(name, mediaType, index) {
|
|
326
|
+
export function imageStorageName(name, mediaType, index) {
|
|
319
327
|
const extension = IMAGE_FILE_EXTENSIONS.get(mediaType) ?? '.img';
|
|
320
328
|
const cleaned = safeName(name);
|
|
321
329
|
if (cleaned && IMAGE_EXTENSION_PATTERN.test(cleaned)) return cleaned;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { extractConnectionEvidence } from './connection-error.mjs';
|
|
2
|
+
import { diagnosticFields } from './diagnostic-details.mjs';
|
|
1
3
|
import { randomUUID } from 'node:crypto';
|
|
2
4
|
|
|
3
5
|
import { t } from './i18n.mjs';
|
|
@@ -189,7 +191,9 @@ export function classifyMessageFailure(error, {
|
|
|
189
191
|
&& userMessage.trim()
|
|
190
192
|
? 'INPUT_INVALID'
|
|
191
193
|
: classifiedCode;
|
|
194
|
+
const details = extractConnectionEvidence(error).details;
|
|
192
195
|
return Object.freeze({
|
|
196
|
+
...(code === 'INTERNAL_UNKNOWN' || details.reason !== 'unknown' || details.httpStatus || details.providerCode ? { details } : {}),
|
|
193
197
|
code,
|
|
194
198
|
reason: safeReason ?? safeFailureReason(error?.code) ?? code,
|
|
195
199
|
message: typeof userMessage === 'string' && userMessage.trim()
|
|
@@ -204,9 +208,16 @@ export function messageFailureText(failure) {
|
|
|
204
208
|
return `${failure.message}\n\n${t('错误码:{code};参考号:{referenceId}', failure)}`;
|
|
205
209
|
}
|
|
206
210
|
|
|
211
|
+
export function messageFailureDiagnostic(error, failure) {
|
|
212
|
+
const evidence = extractConnectionEvidence(error);
|
|
213
|
+
return { code: failure.code, reason: failure.reason, referenceId: failure.referenceId,
|
|
214
|
+
details: evidence.details, errors: evidence.errors };
|
|
215
|
+
}
|
|
216
|
+
|
|
207
217
|
export function setLastMessageFailure(status, error, options) {
|
|
208
218
|
const failure = classifyMessageFailure(error, options);
|
|
209
219
|
status.lastMessageError = failure;
|
|
220
|
+
status.lastError = failure.message;
|
|
210
221
|
return failure;
|
|
211
222
|
}
|
|
212
223
|
|
|
@@ -241,5 +252,6 @@ export function publicMessageFailure(value) {
|
|
|
241
252
|
message: value.message.slice(0, 500),
|
|
242
253
|
referenceId: value.referenceId.slice(0, 40),
|
|
243
254
|
at: value.at,
|
|
255
|
+
...(value.details ? diagnosticFields(value) : {}),
|
|
244
256
|
};
|
|
245
257
|
}
|
|
@@ -138,11 +138,11 @@ export function hasReplyReference(message) {
|
|
|
138
138
|
return objectReference(message?.replyTo);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
export async function promptContentForInboundMessage(message, { signal } = {}) {
|
|
141
|
+
export async function promptContentForInboundMessage(message, { signal, deferImages = false } = {}) {
|
|
142
142
|
if (!hasReplyReference(message)) {
|
|
143
|
-
return promptContentForMessage(message, { signal });
|
|
143
|
+
return promptContentForMessage(message, { signal, deferImages });
|
|
144
144
|
}
|
|
145
145
|
const reference = normalizeReference(await resolveReference(message.replyTo, signal));
|
|
146
|
-
const currentContent = await promptContentForMessage(message, { signal });
|
|
146
|
+
const currentContent = await promptContentForMessage(message, { signal, deferImages });
|
|
147
147
|
return [{ type: 'text', text: replyBlock(reference) }, ...currentContent];
|
|
148
148
|
}
|
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
providerMessageIdsFor,
|
|
65
65
|
} from './semantic/delivery.mjs';
|
|
66
66
|
import {
|
|
67
|
+
messageFailureDiagnostic,
|
|
67
68
|
channelDeliveryFailure,
|
|
68
69
|
clearLastMessageFailure,
|
|
69
70
|
messageFailureText,
|
|
@@ -74,6 +75,15 @@ import { beginStatusReaction } from './status-reaction.mjs';
|
|
|
74
75
|
const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
|
|
75
76
|
const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
|
|
76
77
|
|
|
78
|
+
/**
|
|
79
|
+
* How many times one inbound message may be re-delivered after a failed turn.
|
|
80
|
+
*
|
|
81
|
+
* A failure releases the message's `markSeen` so the next poll retries it, which
|
|
82
|
+
* would otherwise loop forever on a mail that always fails. After this many
|
|
83
|
+
* attempts the mark stays and the message is treated as handled.
|
|
84
|
+
*/
|
|
85
|
+
const MAX_DELIVERY_ATTEMPTS = 3;
|
|
86
|
+
|
|
77
87
|
function cleanText(value) {
|
|
78
88
|
return typeof value === 'string' ? value.trim() : '';
|
|
79
89
|
}
|
|
@@ -83,7 +93,7 @@ function canClaimInteractionReply(message, pending, senderId) {
|
|
|
83
93
|
&& (message.kind !== 'group' || message.addressed === true)
|
|
84
94
|
&& !hasInboundImages(message)
|
|
85
95
|
&& !hasInboundFiles(message)
|
|
86
|
-
&& Boolean(
|
|
96
|
+
&& Boolean(controlTextOf(message));
|
|
87
97
|
}
|
|
88
98
|
|
|
89
99
|
function artifactFailureText(fileName, error, descriptor) {
|
|
@@ -138,6 +148,18 @@ export function createTextBridgeStatus() {
|
|
|
138
148
|
};
|
|
139
149
|
}
|
|
140
150
|
|
|
151
|
+
/**
|
|
152
|
+
* The text a channel's control commands and approval decisions are parsed from.
|
|
153
|
+
*
|
|
154
|
+
* A channel may decorate `content` for the model — the email channel prepends
|
|
155
|
+
* the mail headers so a subject line reaches the model — while `controlText`
|
|
156
|
+
* keeps the undecorated body. Without this a decorated message made `/help`
|
|
157
|
+
* and "批准" unrecognisable.
|
|
158
|
+
*/
|
|
159
|
+
function controlTextOf(message) {
|
|
160
|
+
return cleanText(message?.controlText ?? message?.content);
|
|
161
|
+
}
|
|
162
|
+
|
|
141
163
|
export class TextHarnessBridge {
|
|
142
164
|
#descriptor;
|
|
143
165
|
#bot;
|
|
@@ -161,6 +183,10 @@ export class TextHarnessBridge {
|
|
|
161
183
|
#approvals;
|
|
162
184
|
#batches = new BatchInputManager();
|
|
163
185
|
#interactionCard;
|
|
186
|
+
// Delivery attempts per message id. A failed turn releases its `markSeen` so
|
|
187
|
+
// the next poll can retry, and this counter is what keeps that from becoming
|
|
188
|
+
// an infinite loop on a mail that always fails.
|
|
189
|
+
#deliveryAttempts = new Map();
|
|
164
190
|
|
|
165
191
|
constructor({
|
|
166
192
|
descriptor,
|
|
@@ -229,7 +255,11 @@ export class TextHarnessBridge {
|
|
|
229
255
|
const decision = accessDecision ?? evaluateInboundAccess(this.#accessPolicy, {
|
|
230
256
|
conversationType: kind,
|
|
231
257
|
senderIds: [senderId, cleanText(normalized.senderAlternateId)].filter(Boolean),
|
|
232
|
-
text
|
|
258
|
+
// The same control text the command runner below parses. A channel may
|
|
259
|
+
// decorate `content` for the model (email prepends the mail headers), and
|
|
260
|
+
// a decorated body never looks like a command — so reading `content` here
|
|
261
|
+
// recognized no command at all and skipped the command-permission gate.
|
|
262
|
+
text: controlTextOf(normalized),
|
|
233
263
|
hasImages,
|
|
234
264
|
hasFiles,
|
|
235
265
|
});
|
|
@@ -282,7 +312,7 @@ export class TextHarnessBridge {
|
|
|
282
312
|
|
|
283
313
|
const key = `${normalized.kind}:${normalized.conversationId}`;
|
|
284
314
|
const pending = this.#pendingInteractions.get(key);
|
|
285
|
-
const text =
|
|
315
|
+
const text = controlTextOf(normalized);
|
|
286
316
|
const batchCommand = isBatchInputCommand(text);
|
|
287
317
|
if (batchCommand && normalized.kind === 'group' && normalized.addressed === true) {
|
|
288
318
|
return this.#finishLocalMessage(
|
|
@@ -362,7 +392,12 @@ export class TextHarnessBridge {
|
|
|
362
392
|
key,
|
|
363
393
|
actor: senderId,
|
|
364
394
|
messageId,
|
|
365
|
-
|
|
395
|
+
// Approval decisions are parsed from the control text, exactly like the
|
|
396
|
+
// commands above: a decorated `content` hid "批准" behind the mail headers,
|
|
397
|
+
// so a plain-body approval never claimed its pending request.
|
|
398
|
+
text: hasInboundImages(normalized) || hasInboundFiles(normalized)
|
|
399
|
+
? ''
|
|
400
|
+
: controlTextOf(normalized),
|
|
366
401
|
addressed: normalized.kind !== 'group' || normalized.addressed === true,
|
|
367
402
|
hasPendingQuestion: Boolean(pending),
|
|
368
403
|
questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
|
|
@@ -441,7 +476,7 @@ export class TextHarnessBridge {
|
|
|
441
476
|
const failure = setLastMessageFailure(this.#status, error);
|
|
442
477
|
this.#logger.error?.(
|
|
443
478
|
`[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
|
|
444
|
-
error,
|
|
479
|
+
messageFailureDiagnostic(error, failure),
|
|
445
480
|
);
|
|
446
481
|
}).finally(() => {
|
|
447
482
|
this.#acceptedMessageIds.delete(messageId);
|
|
@@ -500,7 +535,7 @@ export class TextHarnessBridge {
|
|
|
500
535
|
const target = message.replyTarget;
|
|
501
536
|
try {
|
|
502
537
|
const result = await runner(
|
|
503
|
-
|
|
538
|
+
controlTextOf(message),
|
|
504
539
|
this.#harness,
|
|
505
540
|
this.#state,
|
|
506
541
|
key,
|
|
@@ -550,7 +585,7 @@ export class TextHarnessBridge {
|
|
|
550
585
|
const failure = setLastMessageFailure(this.#status, error);
|
|
551
586
|
this.#logger.error?.(
|
|
552
587
|
`[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
|
|
553
|
-
error,
|
|
588
|
+
messageFailureDiagnostic(error, failure),
|
|
554
589
|
);
|
|
555
590
|
await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
|
|
556
591
|
}
|
|
@@ -615,7 +650,7 @@ export class TextHarnessBridge {
|
|
|
615
650
|
}
|
|
616
651
|
|
|
617
652
|
const target = message.replyTarget;
|
|
618
|
-
const text =
|
|
653
|
+
const text = controlTextOf(message);
|
|
619
654
|
const batchSubmission = message.batchSubmission;
|
|
620
655
|
let stream = null;
|
|
621
656
|
let semanticStream = false;
|
|
@@ -710,7 +745,7 @@ export class TextHarnessBridge {
|
|
|
710
745
|
}
|
|
711
746
|
}
|
|
712
747
|
let content = hasImages || hasReply
|
|
713
|
-
? await promptContentForInboundMessage(message, { signal: this.#signal })
|
|
748
|
+
? await promptContentForInboundMessage(message, { signal: this.#signal, deferImages: true })
|
|
714
749
|
: undefined;
|
|
715
750
|
const snapshot = this.#acceptedMessageIds.get(messageId);
|
|
716
751
|
let contextEnhanced = false;
|
|
@@ -790,6 +825,7 @@ export class TextHarnessBridge {
|
|
|
790
825
|
},
|
|
791
826
|
onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
|
|
792
827
|
files: message.files,
|
|
828
|
+
images: message.images,
|
|
793
829
|
},
|
|
794
830
|
});
|
|
795
831
|
stopKeepalive();
|
|
@@ -877,6 +913,8 @@ export class TextHarnessBridge {
|
|
|
877
913
|
clearLastMessageFailure(this.#status);
|
|
878
914
|
}
|
|
879
915
|
}
|
|
916
|
+
// The turn produced an answer, so its retry budget is spent and reset.
|
|
917
|
+
this.#deliveryAttempts.delete(messageId);
|
|
880
918
|
return delivery.receipt;
|
|
881
919
|
} catch (error) {
|
|
882
920
|
stopKeepalive();
|
|
@@ -934,7 +972,7 @@ export class TextHarnessBridge {
|
|
|
934
972
|
: messageFailureText(failure);
|
|
935
973
|
this.#logger.error?.(
|
|
936
974
|
`[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
|
|
937
|
-
error,
|
|
975
|
+
messageFailureDiagnostic(error, failure),
|
|
938
976
|
);
|
|
939
977
|
if (await presentStreamFailure(failureText)) {
|
|
940
978
|
return error.deliveryReceipt;
|
|
@@ -948,6 +986,10 @@ export class TextHarnessBridge {
|
|
|
948
986
|
sendError,
|
|
949
987
|
);
|
|
950
988
|
}
|
|
989
|
+
// The turn produced no answer, so this id is "attempted", not "handled".
|
|
990
|
+
// Releasing the mark lets the next poll retry it; `alreadyRecorded` means
|
|
991
|
+
// an outer caller owns the mark, so it is left alone there.
|
|
992
|
+
if (!alreadyRecorded) await this.#releaseFailedDelivery(messageId);
|
|
951
993
|
return error.deliveryReceipt;
|
|
952
994
|
} finally {
|
|
953
995
|
stopKeepalive();
|
|
@@ -958,6 +1000,43 @@ export class TextHarnessBridge {
|
|
|
958
1000
|
}
|
|
959
1001
|
}
|
|
960
1002
|
|
|
1003
|
+
/**
|
|
1004
|
+
* Release the `markSeen` of a turn that failed without delivering anything,
|
|
1005
|
+
* so the next poll can pick the message up again.
|
|
1006
|
+
*
|
|
1007
|
+
* Bounded: a message is retried at most `MAX_DELIVERY_ATTEMPTS` times, after
|
|
1008
|
+
* which the mark stays and the id becomes a tombstone. That keeps a mail that
|
|
1009
|
+
* always fails from being re-executed forever, while a transient Harness
|
|
1010
|
+
* outage no longer drops it permanently.
|
|
1011
|
+
*
|
|
1012
|
+
* Returns true when the message was released and will be retried.
|
|
1013
|
+
*/
|
|
1014
|
+
async #releaseFailedDelivery(messageId) {
|
|
1015
|
+
const attempts = (this.#deliveryAttempts.get(messageId) ?? 0) + 1;
|
|
1016
|
+
if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
1017
|
+
// Give up: keep the mark so the id is never replayed, and stop tracking
|
|
1018
|
+
// it. The sender has already been told the turn failed.
|
|
1019
|
+
this.#deliveryAttempts.delete(messageId);
|
|
1020
|
+
this.#logger.warn?.(
|
|
1021
|
+
`[dsh-im:${this.#descriptor.key}] giving up on a message after `
|
|
1022
|
+
+ `${attempts} failed delivery attempts; it will not be retried.`,
|
|
1023
|
+
);
|
|
1024
|
+
return false;
|
|
1025
|
+
}
|
|
1026
|
+
this.#deliveryAttempts.set(messageId, attempts);
|
|
1027
|
+
if (typeof this.#state?.unmarkSeen !== 'function') return false;
|
|
1028
|
+
try {
|
|
1029
|
+
await this.#state.unmarkSeen(messageId);
|
|
1030
|
+
return true;
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
this.#logger.warn?.(
|
|
1033
|
+
`[dsh-im:${this.#descriptor.key}] unable to release a failed message for retry:`,
|
|
1034
|
+
error,
|
|
1035
|
+
);
|
|
1036
|
+
return false;
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
961
1040
|
/**
|
|
962
1041
|
* Advance the pending interaction with one answer.
|
|
963
1042
|
*
|
|
@@ -1000,7 +1079,7 @@ export class TextHarnessBridge {
|
|
|
1000
1079
|
}
|
|
1001
1080
|
|
|
1002
1081
|
const target = message.replyTarget;
|
|
1003
|
-
const text =
|
|
1082
|
+
const text = controlTextOf(message);
|
|
1004
1083
|
if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
|
|
1005
1084
|
try {
|
|
1006
1085
|
await this.#bot.sendText(target, t('请用文字回答当前问题。'));
|