dsh-im-ops/src/channels/shared/image-prompt.mjs

352 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { t } from './i18n.mjs';
const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const DEFAULT_MAX_IMAGES = 20;
const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
/**
* Model-facing guidance appended when the Host refuses image input for the
* current model and the same images are re-delivered as workspace files.
*/
export const IMAGE_FILE_FALLBACK_PROMPT = '当前会话模型不支持直接接收图片输入。用户发送的图片已作为文件保存到工作区(见下方文件清单)。请使用可用工具分析这些图片文件后回答,例如 run_code 或 pwsh 读取字节、解析元数据、调用图像处理或 OCR 库;不要假设自己能直接看到图片内容。';
export class ImagePromptError extends Error {
constructor(code, message, userMessage, options = {}) {
super(message, options);
this.name = 'ImagePromptError';
this.code = code;
this.userMessage = userMessage;
}
}
const HOST_ATTACHMENT_USER_MESSAGES = Object.freeze({
MODEL_DOES_NOT_SUPPORT_IMAGES:
'当前模型不支持图片,请用 /models 查看可用模型,再用 /model <序号> 切换后重发。',
IMAGE_TOO_LARGE: '图片超过宿主允许的大小,请压缩后重试。',
IMAGE_TOO_MANY_PIXELS: '图片分辨率过高,请压缩后重试。',
INVALID_IMAGE: '图片内容无效或格式不受支持,请重新发送。',
INVALID_IMAGE_BASE64: '未能读取图片内容,请重新发送。',
IMAGE_TYPE_MISMATCH: '图片格式与实际内容不一致,请重新发送。',
TOO_MANY_IMAGES: '一次发送的图片数量超过宿主限制,请减少后重试。',
IMAGES_TOO_LARGE: '图片总大小超过宿主限制,请减少图片或压缩后重试。',
});
function requestSignal(signal, timeoutMs) {
const timeout = AbortSignal.timeout(timeoutMs);
return signal ? AbortSignal.any([signal, timeout]) : timeout;
}
async function cancelResponseBody(response) {
try {
await response?.body?.cancel?.();
} catch {
// The original download error is more useful than a best-effort cleanup failure.
}
}
export async function fetchImageBuffer(url, {
fetchImpl = fetch,
headers,
signal,
maxBytes = DEFAULT_MAX_IMAGE_BYTES,
timeoutMs = 15_000,
allowedHosts,
} = {}) {
const target = new URL(url);
if (target.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
if (Array.isArray(allowedHosts) && !allowedHosts.some((rule) => (
typeof rule === 'string'
&& (target.hostname === rule
|| (rule.startsWith('.')
&& (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
))) {
throw new Error('Image download URL is not hosted by the messaging platform');
}
const response = await fetchImpl(target, {
method: 'GET',
headers,
signal: requestSignal(signal, timeoutMs),
redirect: 'manual',
});
if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
await cancelResponseBody(response);
throw new ImagePromptError(
'image-redirect-blocked',
`Image download redirect was blocked (HTTP ${response.status})`,
t('图片下载地址发生了重定向,暂时无法读取。'),
);
}
if (!response?.ok) {
await cancelResponseBody(response);
throw new ImagePromptError(
'image-http-error',
`Image download failed with HTTP ${response?.status ?? 'unknown'}`,
t('图片下载失败(HTTP {status}),请重新发送后再试。', { status: response?.status ?? 'unknown' }),
);
}
const declaredLength = Number(response.headers?.get?.('content-length'));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await cancelResponseBody(response);
throw new ImagePromptError(
'image-too-large',
`Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`,
t('图片超过 5 MB,请压缩后重试。'),
);
}
if (response.body?.[Symbol.asyncIterator]) {
const chunks = [];
let size = 0;
for await (const chunk of response.body) {
const data = Buffer.from(chunk);
size += data.length;
if (size > maxBytes) {
await response.body.cancel?.().catch?.(() => undefined);
throw new ImagePromptError(
'image-too-large',
`Image response exceeded ${maxBytes} bytes`,
t('图片超过 5 MB,请压缩后重试。'),
);
}
chunks.push(data);
}
return Buffer.concat(chunks, size);
}
const data = Buffer.from(await response.arrayBuffer());
if (data.length > maxBytes) {
throw new ImagePromptError(
'image-too-large',
`Image response contains ${data.length} bytes; the limit is ${maxBytes}`,
t('图片超过 5 MB,请压缩后重试。'),
);
}
return data;
}
function cleanText(value) {
return typeof value === 'string' ? value.trim() : '';
}
function imageSources(message) {
return Array.isArray(message?.images) ? message.images.filter(Boolean) : [];
}
function safeName(value) {
if (typeof value !== 'string') return undefined;
const name = value
.replaceAll('\\', '/')
.split('/')
.at(-1)
?.replace(/[\u0000-\u001f\u007f]/g, '')
.trim()
.slice(0, 255);
return name || undefined;
}
function detectedImageMediaType(data) {
if (data.length >= 8
&& data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
&& data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) {
return 'image/png';
}
if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
return 'image/jpeg';
}
if (data.length >= 6) {
const signature = data.subarray(0, 6).toString('ascii');
if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif';
}
if (data.length >= 12
&& data.subarray(0, 4).toString('ascii') === 'RIFF'
&& data.subarray(8, 12).toString('ascii') === 'WEBP') {
return 'image/webp';
}
return null;
}
function loadedImage(value) {
if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
return { data: Buffer.from(value) };
}
const raw = value?.data ?? value?.buffer;
if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
return {
data: Buffer.from(raw),
name: value?.name ?? value?.filename,
};
}
return null;
}
export function hasInboundImages(message) {
return imageSources(message).length > 0;
}
export function hasInboundPrompt(message) {
return Boolean(cleanText(message?.content)) || hasInboundImages(message);
}
export async function promptContentForMessage(message, {
signal,
maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
maxImages = DEFAULT_MAX_IMAGES,
maxTotalImageBytes = DEFAULT_MAX_TOTAL_IMAGE_BYTES,
} = {}) {
const sources = imageSources(message);
if (sources.length > maxImages) {
throw new ImagePromptError(
'too-many-images',
`Image message contains ${sources.length} images; the limit is ${maxImages}`,
t('一次最多只能处理 {maxImages} 张图片。', { maxImages }),
);
}
const text = cleanText(message?.content);
const content = [];
let totalImageBytes = 0;
if (text) content.push({ type: 'text', text });
else if (sources.length > 0) content.push({ type: 'text', text: t(DEFAULT_IMAGE_PROMPT) });
for (const [index, source] of sources.entries()) {
signal?.throwIfAborted();
if (Number.isFinite(source?.size) && source.size > maxImageBytes) {
throw new ImagePromptError(
'image-too-large',
`Image ${index + 1} declares ${source.size} bytes; the limit is ${maxImageBytes}`,
t('图片超过 5 MB,请压缩后重试。'),
);
}
if (Number.isFinite(source?.size) && totalImageBytes + source.size > maxTotalImageBytes) {
throw new ImagePromptError(
'images-too-large',
`Images declare more than ${maxTotalImageBytes} bytes in total`,
t('一次发送的图片总大小过大,请减少图片数量或压缩后重试。'),
);
}
let result;
try {
result = source?.data === undefined
? await source?.load?.({ signal, maxBytes: maxImageBytes })
: source.data;
} catch (error) {
if (signal?.aborted || error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
if (error instanceof ImagePromptError) throw error;
throw new ImagePromptError(
'image-download-failed',
`Unable to download image ${index + 1}: ${error?.message ?? String(error)}`,
t('图片下载失败,请重新发送后再试。'),
{ cause: error },
);
}
const loaded = loadedImage(result);
if (!loaded?.data.length) {
throw new ImagePromptError(
'invalid-image-data',
`Image ${index + 1} returned no data`,
t('未能读取图片内容,请重新发送。'),
);
}
if (loaded.data.length > maxImageBytes) {
throw new ImagePromptError(
'image-too-large',
`Image ${index + 1} contains ${loaded.data.length} bytes; the limit is ${maxImageBytes}`,
t('图片超过 5 MB,请压缩后重试。'),
);
}
if (totalImageBytes + loaded.data.length > maxTotalImageBytes) {
throw new ImagePromptError(
'images-too-large',
`Images contain more than ${maxTotalImageBytes} bytes in total`,
t('一次发送的图片总大小过大,请减少图片数量或压缩后重试。'),
);
}
totalImageBytes += loaded.data.length;
const mediaType = detectedImageMediaType(loaded.data);
if (!mediaType) {
throw new ImagePromptError(
'unsupported-image-type',
`Image ${index + 1} is not JPEG, PNG, GIF, or WebP`,
t('暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。'),
);
}
content.push({
type: 'image',
mediaType,
data: loaded.data.toString('base64'),
...(safeName(loaded.name ?? source?.name) ? { name: safeName(loaded.name ?? source?.name) } : {}),
});
}
return content;
}
/** Return only allowlisted, user-safe image failure details. */
export function imagePromptDiagnostic(error) {
if (error instanceof ImagePromptError) {
return {
code: 'image-prompt-error',
reason: error.code,
userMessage: error.userMessage,
};
}
if (error?.code !== 'attachment-error' || typeof error?.details?.reason !== 'string') {
return null;
}
const reason = error.details.reason;
const userMessage = Object.hasOwn(HOST_ATTACHMENT_USER_MESSAGES, reason)
? t(HOST_ATTACHMENT_USER_MESSAGES[reason])
: null;
return userMessage ? { code: 'attachment-error', reason, userMessage } : null;
}
export function imagePromptUserMessage(error) {
return imagePromptDiagnostic(error)?.userMessage ?? null;
}
const IMAGE_FILE_EXTENSIONS = new Map([
['image/png', '.png'],
['image/jpeg', '.jpg'],
['image/gif', '.gif'],
['image/webp', '.webp'],
]);
const IMAGE_EXTENSION_PATTERN = /\.(?:png|jpe?g|gif|webp)$/i;
function imageStorageName(name, mediaType, index) {
const extension = IMAGE_FILE_EXTENSIONS.get(mediaType) ?? '.img';
const cleaned = safeName(name);
if (cleaned && IMAGE_EXTENSION_PATTERN.test(cleaned)) return cleaned;
return `${cleaned ?? `image-${index + 1}`}${extension}`;
}
/**
* Convert already-admitted image content blocks into inbound file sources so
* the same bytes can reach a non-vision model as workspace files — the path
* ordinary uploads such as zip archives already take.
*/
export function imageFileSourcesFromContent(content) {
if (!Array.isArray(content)) return [];
return content
.filter((part) => part?.type === 'image')
.map((part, index) => ({
name: imageStorageName(part.name, part.mediaType, index),
...(typeof part.mediaType === 'string' && part.mediaType.trim()
? { mediaType: part.mediaType.trim() }
: {}),
data: Buffer.from(typeof part.data === 'string' ? part.data : '', 'base64'),
}));
}
/** Return the same content with every image block removed. */
export function contentWithoutImages(content) {
return Array.isArray(content) ? content.filter((part) => part?.type !== 'image') : content;
}
/** Whether an error is the Host rejecting image input for a non-vision model. */
export function isModelImageRejection(error) {
return error?.code === 'attachment-error'
&& error?.details?.reason === 'MODEL_DOES_NOT_SUPPORT_IMAGES';
}