@xmanrui/dsh-im 4.22.0 → 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.
Files changed (137) hide show
  1. package/README.en.md +3 -3
  2. package/README.md +3 -3
  3. package/lib/client.js +2505 -1152
  4. package/lib/index.js +290 -299
  5. package/package.json +8 -1
  6. package/plugin-src/client/channel-card-meta.js +2 -0
  7. package/plugin-src/client/channel-logos.js +11 -0
  8. package/plugin-src/client/channels/dingtalk/api.js +5 -1
  9. package/plugin-src/client/channels/dingtalk/index.js +15 -13
  10. package/plugin-src/client/channels/email/api.js +52 -0
  11. package/plugin-src/client/channels/email/index.js +728 -0
  12. package/plugin-src/client/channels/email/styles.js +51 -0
  13. package/plugin-src/client/channels/feishu/api.js +4 -2
  14. package/plugin-src/client/channels/feishu/index.js +16 -3
  15. package/plugin-src/client/channels/imessage/index.js +2 -1
  16. package/plugin-src/client/channels/office/api.js +2 -0
  17. package/plugin-src/client/channels/office/index.js +6 -5
  18. package/plugin-src/client/channels/qq/api.js +7 -0
  19. package/plugin-src/client/channels/qq/index.js +16 -3
  20. package/plugin-src/client/channels/shared/token-api.js +5 -1
  21. package/plugin-src/client/channels/shared/token-channel.js +28 -3
  22. package/plugin-src/client/channels/slack/index.js +2 -1
  23. package/plugin-src/client/channels/wecom/api.js +7 -1
  24. package/plugin-src/client/channels/wecom/index.js +16 -3
  25. package/plugin-src/client/channels/wecom-app/api.js +6 -1
  26. package/plugin-src/client/channels/wecom-app/index.js +16 -3
  27. package/plugin-src/client/channels/weixin/api.js +2 -1
  28. package/plugin-src/client/channels/weixin/connection-error.js +1 -71
  29. package/plugin-src/client/channels/whatsapp/api.js +5 -0
  30. package/plugin-src/client/channels/whatsapp/index.js +16 -3
  31. package/plugin-src/client/connection-error.js +87 -0
  32. package/plugin-src/client/global-settings.js +81 -1
  33. package/plugin-src/client/i18n.js +61 -0
  34. package/plugin-src/client/index.js +30 -2
  35. package/plugin-src/client/last-message-error.js +2 -1
  36. package/plugin-src/client/styles.js +6 -0
  37. package/plugin-src/host/build.mjs +4 -0
  38. package/plugin-src/host/channels/dingtalk/production.mjs +2 -0
  39. package/plugin-src/host/channels/dingtalk/rpc.mjs +7 -4
  40. package/plugin-src/host/channels/email/availability.mjs +60 -0
  41. package/plugin-src/host/channels/email/index.mjs +36 -0
  42. package/plugin-src/host/channels/email/production.mjs +58 -0
  43. package/plugin-src/host/channels/email/rpc.mjs +163 -0
  44. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  45. package/plugin-src/host/channels/feishu/rpc.mjs +7 -4
  46. package/plugin-src/host/channels/imessage/rpc.mjs +13 -6
  47. package/plugin-src/host/channels/office/rpc.mjs +3 -1
  48. package/plugin-src/host/channels/qq/production.mjs +2 -0
  49. package/plugin-src/host/channels/qq/rpc.mjs +5 -3
  50. package/plugin-src/host/channels/shared/production.mjs +52 -5
  51. package/plugin-src/host/channels/shared/rpc.mjs +5 -3
  52. package/plugin-src/host/channels/shared/startup-error.mjs +4 -3
  53. package/plugin-src/host/channels/shared/startup.mjs +11 -6
  54. package/plugin-src/host/channels/slack/production.mjs +2 -0
  55. package/plugin-src/host/channels/slack/rpc.mjs +5 -3
  56. package/plugin-src/host/channels/wecom/production.mjs +2 -0
  57. package/plugin-src/host/channels/wecom/rpc.mjs +5 -3
  58. package/plugin-src/host/channels/wecom-app/production.mjs +2 -0
  59. package/plugin-src/host/channels/wecom-app/rpc.mjs +5 -3
  60. package/plugin-src/host/channels/weixin/production.mjs +2 -0
  61. package/plugin-src/host/channels/whatsapp/production.mjs +2 -0
  62. package/plugin-src/host/channels/whatsapp/rpc.mjs +5 -3
  63. package/plugin-src/host/delivery-adapter.mjs +11 -0
  64. package/plugin-src/host/image-input-rpc.mjs +24 -0
  65. package/plugin-src/host/inbound-ttl-rpc.mjs +6 -1
  66. package/plugin-src/host/index.mjs +3 -0
  67. package/plugin-src/management-rpc.mjs +12 -2
  68. package/scripts/verify-package.mjs +3 -1
  69. package/src/channels/dingtalk/connection-error.mjs +5 -12
  70. package/src/channels/dingtalk/device-auth.mjs +4 -1
  71. package/src/channels/dingtalk/dingtalk-bridge.mjs +6 -4
  72. package/src/channels/dingtalk/dingtalk-controller.mjs +60 -45
  73. package/src/channels/dingtalk/dingtalk-runtime.mjs +20 -4
  74. package/src/channels/discord/discord-api.mjs +3 -3
  75. package/src/channels/discord/discord-runtime.mjs +18 -12
  76. package/src/channels/email/config-store.mjs +194 -0
  77. package/src/channels/email/email-api.mjs +20 -0
  78. package/src/channels/email/email-bridge.mjs +17 -0
  79. package/src/channels/email/email-controller.mjs +824 -0
  80. package/src/channels/email/email-runtime.mjs +692 -0
  81. package/src/channels/email/harness-client.mjs +7 -0
  82. package/src/channels/email/mail-format.mjs +91 -0
  83. package/src/channels/email/state-store.mjs +176 -0
  84. package/src/channels/email/transport.mjs +64 -0
  85. package/src/channels/email/transports/agent-mail.mjs +593 -0
  86. package/src/channels/email/transports/agently-cli.mjs +288 -0
  87. package/src/channels/email/transports/imap-smtp.mjs +165 -0
  88. package/src/channels/feishu/bridge.mjs +8 -7
  89. package/src/channels/feishu/feishu-runtime.mjs +15 -8
  90. package/src/channels/feishu/group-message-permission-manager.mjs +2 -1
  91. package/src/channels/feishu/message-utils.mjs +3 -2
  92. package/src/channels/feishu/multi-bot-controller.mjs +56 -47
  93. package/src/channels/feishu/registration-manager.mjs +8 -4
  94. package/src/channels/feishu/repair-manager.mjs +2 -1
  95. package/src/channels/imessage/runtime.mjs +9 -5
  96. package/src/channels/office/office-controller.mjs +23 -12
  97. package/src/channels/office/office-runtime.mjs +6 -3
  98. package/src/channels/office/office-transport.mjs +1 -0
  99. package/src/channels/qq/qq-bridge.mjs +7 -5
  100. package/src/channels/qq/qq-controller.mjs +56 -42
  101. package/src/channels/qq/qq-runtime.mjs +16 -11
  102. package/src/channels/shared/bot-workspace-store.mjs +29 -10
  103. package/src/channels/shared/connection-error.mjs +191 -0
  104. package/src/channels/shared/connection-test.mjs +3 -1
  105. package/src/channels/shared/conversation-state-store.mjs +46 -1
  106. package/src/channels/shared/diagnostic-details.mjs +95 -0
  107. package/src/channels/shared/harness-client.mjs +63 -18
  108. package/src/channels/shared/i18n-en/diagnostics.mjs +55 -0
  109. package/src/channels/shared/i18n-en/email.mjs +25 -0
  110. package/src/channels/shared/i18n-en/image-input.mjs +17 -0
  111. package/src/channels/shared/i18n-en/shared-a.mjs +1 -0
  112. package/src/channels/shared/i18n-en/shared-c.mjs +1 -1
  113. package/src/channels/shared/i18n-en.mjs +6 -0
  114. package/src/channels/shared/image-input-policy.mjs +35 -0
  115. package/src/channels/shared/image-input-settings-store.mjs +60 -0
  116. package/src/channels/shared/image-input.mjs +124 -0
  117. package/src/channels/shared/image-prompt.mjs +15 -7
  118. package/src/channels/shared/message-failure.mjs +12 -0
  119. package/src/channels/shared/semantic/reply-reference.mjs +3 -3
  120. package/src/channels/shared/text-harness-bridge.mjs +90 -11
  121. package/src/channels/shared/token-bot-controller.mjs +52 -47
  122. package/src/channels/slack/slack-api.mjs +3 -3
  123. package/src/channels/slack/slack-controller.mjs +59 -51
  124. package/src/channels/slack/slack-runtime.mjs +18 -12
  125. package/src/channels/telegram/telegram-api.mjs +2 -2
  126. package/src/channels/telegram/telegram-runtime.mjs +17 -13
  127. package/src/channels/wecom/wecom-bridge.mjs +15 -6
  128. package/src/channels/wecom/wecom-controller.mjs +51 -37
  129. package/src/channels/wecom/wecom-runtime.mjs +10 -6
  130. package/src/channels/wecom-app/wecom-app-bridge.mjs +7 -5
  131. package/src/channels/wecom-app/wecom-app-controller.mjs +50 -37
  132. package/src/channels/wecom-app/wecom-app-runtime.mjs +2 -1
  133. package/src/channels/weixin/connection-error.mjs +26 -92
  134. package/src/channels/weixin/diagnostic-details.mjs +1 -63
  135. package/src/channels/weixin/weixin-bridge.mjs +7 -5
  136. package/src/channels/whatsapp/whatsapp-controller.mjs +34 -17
  137. package/src/channels/whatsapp/whatsapp-runtime.mjs +35 -8
@@ -0,0 +1,25 @@
1
+ /** English projections for host-side Email channel text. */
2
+ export default Object.freeze({
3
+ '邮箱': 'Email',
4
+ '邮箱地址格式不正确': 'The email address format is invalid',
5
+ '邮箱地址与应用密码均为必填': 'Both the email address and the app password are required',
6
+ '请选择邮箱服务商或填写 IMAP/SMTP 服务器地址':
7
+ 'Choose a mail provider or fill in the IMAP/SMTP server addresses',
8
+ '必须至少配置一个允许发件人':
9
+ 'At least one allowed sender must be configured',
10
+ '邮箱连接失败,请检查地址、应用密码与服务器设置':
11
+ 'Could not connect to the mailbox; check the address, app password, and server settings',
12
+ '未找到该邮箱配置': 'No mailbox configuration was found',
13
+ '邮箱凭据已丢失,请重新绑定':
14
+ 'The mailbox credential is missing; please bind the mailbox again',
15
+ '邮箱尚未连接': 'The mailbox is not connected yet',
16
+ '邮箱通道连接正常。': 'The Email channel is connected.',
17
+ '邮箱通道运行正常': 'The Email channel is running normally',
18
+ '邮箱通道尚未连接': 'The Email channel is not connected yet',
19
+ '邮箱访问策略同步失败,请重试': 'Failed to sync the mailbox access policy; please retry',
20
+ '当前环境不支持会话绑定': 'Session binding is not supported in this environment',
21
+ '该接入方式不需要扫码授权': 'This access method does not use QR authorization',
22
+ '授权信息缺失,请重新扫码授权': 'The authorization is missing; please scan again',
23
+ '扫码授权尚未开始': 'QR authorization has not been started',
24
+ '扫码授权已超时,请重新发起': 'QR authorization timed out; please start it again',
25
+ });
@@ -0,0 +1,17 @@
1
+ export default {
2
+ "图片原文件已保存到工作区,文件清单中的图片序号与本条消息一致。图片内容块可能经过缩放或压缩;需要查看细节时可用工具读取原文件。": "Original images are saved in the workspace. Image numbers in the file list match this message. Image blocks may be resized or compressed; use tools to read originals for details.",
3
+ "部分图片未直接提供给模型,图片序号:{indexes}。原文件已保存,请使用可用工具读取或分析;不要假设自己已看到这些图片。": "Some images were not provided directly to the model: {indexes}. Originals are saved; use available tools to read or analyze them. Do not assume you have seen these images.",
4
+ "宿主拒绝了本次图片输入。原图已保存到工作区,请根据文件清单使用可用工具读取或分析,不要假设自己已看到图片。": "The Host rejected the image input. Originals are saved in the workspace; use the file list and available tools to read or analyze them. Do not assume you have seen the images.",
5
+ "图片超过原图接收上限 {maxMb} MB,请压缩后重试,或在通用设置的附件中调整上限。": "The image exceeds the original-image receipt limit of {maxMb} MB. Compress it and retry, or adjust the limit in General settings > Attachments.",
6
+ "图片 {index}(原图文件:{name})": "Image {index} (original file: {name})",
7
+ "图片限制无效:请输入正整数,原图接收上限和总量上限不能小于单图上限。": "Invalid image limits: enter positive integers. The receipt and total limits must be at least the per-image limit.",
8
+ "无法读取或保存图片设置,请稍后重试。": "Could not read or save image settings. Try again later.",
9
+ "原图接收上限 (MB)": "Original-image receipt limit (MB)",
10
+ "模型单图上限 (MB)": "Model per-image limit (MB)",
11
+ "模型图片总量上限 (MB)": "Model total image limit (MB)",
12
+ "每条消息最多图片数": "Maximum images per message",
13
+ "图片输入": "Image input",
14
+ "适用于支持图片的聊天渠道。原图按附件保留时长保存,发送给模型的副本会自动缩放或压缩;无法直接发送时交给模型按文件处理。": "Applies to chat channels that support images. Originals follow attachment retention settings. Model copies are resized or compressed automatically; images that cannot be sent directly are provided as files.",
15
+ "保存图片设置": "Save image settings",
16
+ "正在读取图片设置…": "Loading image settings…"
17
+ };
@@ -3,6 +3,7 @@ export default {
3
3
  '这个问题已在其他客户端处理,无需再次回答。':
4
4
  'This question has already been answered from another client; no further reply is needed.',
5
5
  '任务已完成。': 'Task completed.',
6
+ '本轮处理已结束,没有文本回复。': 'This turn has ended with no text reply.',
6
7
  '结果文件': 'result file',
7
8
  '结果文件「{name}」的发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。':
8
9
  'The delivery result of the result file "{name}" could not be confirmed. Please check whether it already arrived in the chat before retrying.',
@@ -8,7 +8,7 @@ export default {
8
8
  '{channel}配置格式错误。请检查其数据目录中的 config.json 和 workspaces.json,修复后重启 DSH。详细原因请查看启动日志。': 'The {channel} configuration is invalid. Check config.json and workspaces.json in its data directory, then restart DSH. See the startup log for details.',
9
9
  '{channel}配置格式错误。请检查其数据目录中的 config.json,修复后重启 DSH。详细原因请查看启动日志。': 'The {channel} configuration is invalid. Check config.json in its data directory, then restart DSH. See the startup log for details.',
10
10
  '无法读取或写入{channel}配置。请检查其数据目录的访问权限,修复后重启 DSH。详细原因请查看启动日志。': 'The {channel} configuration could not be read or written. Check access to its data directory, then restart DSH. See the startup log for details.',
11
- '{channel}初始化失败。请查看 DSH 启动日志中 failed to activate {id} 后的错误,修复后重启 DSH。': '{channel} initialization failed. Check the error after "failed to activate {id}" in the DSH startup log, fix it, then restart DSH.',
11
+ '{channel}初始化失败。请展开诊断详情,并通过参考号查找 DSH 启动日志,修复后重启 DSH。': '{channel} initialization failed. Expand the diagnostic details and find the reference in the DSH startup log, fix the issue, then restart DSH.',
12
12
  // history-command.mjs / command help
13
13
  '/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)':
14
14
  '/history [count] Preview recent messages (default 3, maximum 5)',
@@ -1,3 +1,5 @@
1
+ import imageInput from './i18n-en/image-input.mjs';
2
+ import diagnostics from './i18n-en/diagnostics.mjs';
1
3
  // English translations for host-side user-facing text.
2
4
  // Keys are the exact Chinese literals passed to t() in src/channels/**.
3
5
  // Chinese output is the identity default and needs no entries here.
@@ -17,9 +19,12 @@ import telegram from './i18n-en/telegram.mjs';
17
19
  import discord from './i18n-en/discord.mjs';
18
20
  import whatsapp from './i18n-en/whatsapp.mjs';
19
21
  import office from './i18n-en/office.mjs';
22
+ import email from './i18n-en/email.mjs';
20
23
 
21
24
  export const EN = Object.freeze(Object.assign(
22
25
  {},
26
+ diagnostics,
27
+ imageInput,
23
28
  sharedA,
24
29
  sharedB,
25
30
  sharedC,
@@ -34,4 +39,5 @@ export const EN = Object.freeze(Object.assign(
34
39
  discord,
35
40
  whatsapp,
36
41
  office,
42
+ email,
37
43
  ));
@@ -0,0 +1,35 @@
1
+ export const IMAGE_MEGABYTE = 1024 * 1024;
2
+
3
+ export const DEFAULT_IMAGE_INPUT_SETTINGS = Object.freeze({
4
+ maxDownloadMb: 30,
5
+ maxImageMb: 5,
6
+ maxTotalMb: 20,
7
+ maxImages: 20,
8
+ });
9
+
10
+ /** Shared by the settings form and Host; reject incomplete or unsafe values. */
11
+ export function normalizeImageInputSettings(value) {
12
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
13
+ const result = {};
14
+ for (const key of Object.keys(DEFAULT_IMAGE_INPUT_SETTINGS)) {
15
+ const input = value[key];
16
+ const number = typeof input === 'string' && /^\d+$/.test(input.trim())
17
+ ? Number(input.trim()) : input;
18
+ if (!Number.isSafeInteger(number) || number < 1
19
+ || !Number.isSafeInteger(number * IMAGE_MEGABYTE)) return null;
20
+ result[key] = number;
21
+ }
22
+ if (result.maxDownloadMb < result.maxImageMb || result.maxTotalMb < result.maxImageMb) return null;
23
+ return Object.freeze(result);
24
+ }
25
+
26
+ export function imageInputLimits(settings = DEFAULT_IMAGE_INPUT_SETTINGS) {
27
+ const normalized = normalizeImageInputSettings(settings);
28
+ if (!normalized) throw new TypeError('Invalid image input settings');
29
+ return Object.freeze({
30
+ maxDownloadBytes: normalized.maxDownloadMb * IMAGE_MEGABYTE,
31
+ maxImageBytes: normalized.maxImageMb * IMAGE_MEGABYTE,
32
+ maxTotalImageBytes: normalized.maxTotalMb * IMAGE_MEGABYTE,
33
+ maxImages: normalized.maxImages,
34
+ });
35
+ }
@@ -0,0 +1,60 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve } from 'node:path';
5
+
6
+ import { DEFAULT_IMAGE_INPUT_SETTINGS, normalizeImageInputSettings } from './image-input-policy.mjs';
7
+
8
+ const stores = new Map();
9
+
10
+ export class ImageInputSettingsStore {
11
+ #path;
12
+ #settings = DEFAULT_IMAGE_INPUT_SETTINGS;
13
+ #ready;
14
+ #writes = Promise.resolve();
15
+
16
+ constructor(path) { this.#path = path; }
17
+
18
+ async get() {
19
+ this.#ready ??= this.#load().catch((error) => { this.#ready = null; throw error; });
20
+ await this.#ready;
21
+ return { ...this.#settings };
22
+ }
23
+
24
+ async #load() {
25
+ let raw;
26
+ try { raw = await readFile(this.#path, 'utf8'); }
27
+ catch (error) { if (error.code === 'ENOENT') return; throw error; }
28
+ const document = JSON.parse(raw);
29
+ const settings = document.version === 1 && normalizeImageInputSettings(document.imageInput);
30
+ if (!settings) throw new Error('Invalid image input settings document');
31
+ this.#settings = settings;
32
+ }
33
+
34
+ async set(value) {
35
+ const settings = normalizeImageInputSettings(value);
36
+ if (!settings) throw new TypeError('Invalid image input settings');
37
+ const operation = this.#writes.catch(() => {}).then(async () => {
38
+ await this.get();
39
+ await mkdir(dirname(this.#path), { recursive: true, mode: 0o700 });
40
+ const temporary = `${this.#path}.${randomUUID()}.tmp`;
41
+ try {
42
+ await writeFile(temporary, `${JSON.stringify({ version: 1, imageInput: settings }, null, 2)}\n`,
43
+ { mode: 0o600, flag: 'wx' });
44
+ await rename(temporary, this.#path);
45
+ this.#settings = settings;
46
+ } finally { await rm(temporary, { force: true }); }
47
+ return { ...settings };
48
+ });
49
+ this.#writes = operation;
50
+ return operation;
51
+ }
52
+ }
53
+
54
+ /** One settings owner per DSH home, shared by the UI and channel Harness clients. */
55
+ export function getImageInputSettingsStore(config = {}) {
56
+ const root = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
57
+ const path = join(root, 'integrations', 'dsh-im', 'image-input-settings.json');
58
+ if (!stores.has(path)) stores.set(path, new ImageInputSettingsStore(path));
59
+ return stores.get(path);
60
+ }
@@ -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
- t('图片超过 5 MB,请压缩后重试。'),
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
- t('图片超过 5 MB,请压缩后重试。'),
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
- t('图片超过 5 MB,请压缩后重试。'),
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
  }