Files

365 lines
14 KiB
JavaScript
Raw Permalink 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.
// 上传附件的本地预处理层:MiniMax 负责感知,DeepSeek 只消费结构化证据。
const crypto = require("crypto");
const { gatewayConfig } = require("./multimodal-config");
const cache = new Map();
const CACHE_TTL_MS = 10 * 60 * 1000;
const MAX_IMAGES_PER_REQUEST = 8;
const MAX_IMAGE_VALUE_LENGTH = 12 * 1024 * 1024;
const MAX_BINARY_BYTES = 25 * 1024 * 1024;
const MAX_PDF_PAGES = 20;
const MAX_PDF_TEXT_LENGTH = 32000;
const SYSTEM_PROMPT = [
"你是附件理解服务。只根据附件可见内容回答,不要猜测。",
"返回严格 JSON,不要 Markdown:",
'{"summary":"简短概括","items":[{"kind":"ocr|scene|ui|chart","text":"证据","confidence":0.0}]}',
"OCR 保留原文;图表需给出数据、单位与趋势;无法确认时降低 confidence。",
].join("\n");
function sha256(value) {
return crypto.createHash("sha256").update(String(value || "")).digest("hex");
}
function textFromPart(part) {
if (!part || typeof part !== "object") return "";
return String(part.text || part.content || "").trim();
}
function urlFromPart(part) {
if (!part || typeof part !== "object") return "";
const inputImage = part.input_image && typeof part.input_image === "object" ? part.input_image : {};
const inputFile = part.input_file && typeof part.input_file === "object" ? part.input_file : {};
const inputAudio = part.input_audio && typeof part.input_audio === "object" ? part.input_audio : {};
const candidate =
(part.image_url && (part.image_url.url || part.image_url)) ||
(part.file_url && (part.file_url.url || part.file_url)) ||
(part.audio_url && (part.audio_url.url || part.audio_url)) ||
inputImage.image_url ||
inputImage.url ||
inputImage.data ||
inputFile.file_url ||
inputFile.file_data ||
inputFile.url ||
inputFile.data ||
inputAudio.audio_url ||
inputAudio.url ||
inputAudio.data ||
part.url ||
part.data ||
part.file_data ||
"";
return typeof candidate === "string" ? candidate.trim() : "";
}
function kindFromPart(part, value) {
const type = String((part && part.type) || "").toLowerCase();
const name = String((part && (part.filename || part.name)) || "").toLowerCase();
const mime = String((part && (part.mime_type || part.mimeType)) || "").toLowerCase();
if (type.includes("image") || /^data:image\//i.test(value) || /\.(png|jpe?g|gif|webp|bmp)$/i.test(name)) return "image";
if (type.includes("audio") || /^data:audio\//i.test(value) || /\.(mp3|wav|m4a|aac|ogg|webm)$/i.test(name)) return "audio";
if (type.includes("file") || type.includes("pdf") || mime.includes("pdf") || /\.pdf$/i.test(name)) return "document";
return "";
}
function collectAttachments(request) {
const attachments = [];
const input = request && request.input;
if (!Array.isArray(input)) return attachments;
for (const message of input) {
if (!message || !Array.isArray(message.content)) continue;
for (const part of message.content) {
const value = urlFromPart(part);
const kind = kindFromPart(part, value);
if (!kind) continue;
const nestedFile = part.input_file && typeof part.input_file === "object" ? part.input_file : {};
const nestedAudio = part.input_audio && typeof part.input_audio === "object" ? part.input_audio : {};
const format = String(nestedAudio.format || "").trim().toLowerCase();
const name = String(
part.filename ||
part.name ||
nestedFile.filename ||
nestedFile.name ||
(kind === "audio" && format ? `audio.${format}` : "")
);
attachments.push({
id: `${kind}:${sha256(value || `${name}:${textFromPart(part)}`)}`,
kind,
value,
name,
mimeType: String(part.mime_type || part.mimeType || nestedFile.mime_type || nestedFile.mimeType || ""),
});
}
}
return attachments;
}
function extractContentText(payload) {
const message = payload && payload.choices && payload.choices[0] && payload.choices[0].message;
const content = message && message.content;
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content.map((part) => (typeof part === "string" ? part : (part && (part.text || part.content)) || "")).join("");
}
return "";
}
function parseEvidence(text) {
const raw = String(text || "").trim();
const fenced = raw.replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "");
try {
const parsed = JSON.parse(fenced);
return {
summary: String(parsed.summary || "").trim(),
items: Array.isArray(parsed.items)
? parsed.items.slice(0, 40).map((item) => ({
kind: String((item && item.kind) || "scene"),
text: String((item && item.text) || "").trim(),
confidence: Number.isFinite(Number(item && item.confidence)) ? Number(item.confidence) : null,
})).filter((item) => item.text)
: [],
};
} catch {
return { summary: raw.slice(0, 6000), items: [] };
}
}
function chatEndpoint(baseUrl) {
const base = String(baseUrl || "").replace(/\/+$/, "");
if (/\/chat\/completions$/i.test(base)) return base;
if (/\/v\d+$/i.test(base)) return `${base}/chat/completions`;
return `${base}/v1/chat/completions`;
}
function transcriptionEndpoint(baseUrl) {
const base = String(baseUrl || "").replace(/\/+$/, "");
if (/\/v\d+$/i.test(base)) return `${base}/audio/transcriptions`;
return `${base}/v1/audio/transcriptions`;
}
async function binaryFromAsset(asset, requestFetch) {
const value = String(asset.value || "").trim();
if (!value) throw new Error("附件没有可读取的数据或 URL");
if (/^data:/i.test(value)) {
const comma = value.indexOf(",");
if (comma < 0) throw new Error("附件 data URL 格式无效");
const meta = value.slice(0, comma);
const body = value.slice(comma + 1);
const buffer = /;base64/i.test(meta)
? Buffer.from(body, "base64")
: Buffer.from(decodeURIComponent(body), "utf-8");
if (buffer.length > MAX_BINARY_BYTES) throw new Error("附件超过 25MB 限制");
return buffer;
}
if (/^https?:\/\//i.test(value)) {
const response = await requestFetch(value, { headers: { "user-agent": "codex-market" } });
if (!response.ok) throw new Error(`读取附件失败(HTTP ${response.status})`);
const size = Number(response.headers && response.headers.get && response.headers.get("content-length"));
if (Number.isFinite(size) && size > MAX_BINARY_BYTES) throw new Error("附件超过 25MB 限制");
const buffer = Buffer.from(await response.arrayBuffer());
if (buffer.length > MAX_BINARY_BYTES) throw new Error("附件超过 25MB 限制");
return buffer;
}
const buffer = Buffer.from(value, "base64");
if (!buffer.length || buffer.length > MAX_BINARY_BYTES) throw new Error("附件内容无效或超过 25MB 限制");
return buffer;
}
async function understandPdf(asset, requestFetch) {
const data = await binaryFromAsset(asset, requestFetch);
const pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
const task = pdfjs.getDocument({ data: new Uint8Array(data), isEvalSupported: false });
const document = await task.promise;
const items = [];
for (let pageNumber = 1; pageNumber <= Math.min(document.numPages, MAX_PDF_PAGES); pageNumber += 1) {
const page = await document.getPage(pageNumber);
const content = await page.getTextContent();
const text = content.items.map((item) => String(item.str || "")).join(" ").replace(/\s+/g, " ").trim();
if (text) items.push({ kind: "document", text: text.slice(0, MAX_PDF_TEXT_LENGTH), page: pageNumber, confidence: 1 });
}
if (!items.length) {
return {
summary: "PDF 未提取到可复制文本,可能是扫描件;需要后续 OCR/页面视觉解析。",
items: [],
};
}
return {
summary: `PDF 共 ${document.numPages} 页,已提取 ${items.length} 页文本。`,
items,
};
}
function audioMimeType(asset) {
if (asset.mimeType) return asset.mimeType;
const name = String(asset.name || "").toLowerCase();
if (name.endsWith(".wav")) return "audio/wav";
if (name.endsWith(".mp3")) return "audio/mpeg";
if (name.endsWith(".m4a")) return "audio/mp4";
if (name.endsWith(".aac")) return "audio/aac";
if (name.endsWith(".ogg")) return "audio/ogg";
return "audio/webm";
}
async function understandAudio(asset, config, requestFetch) {
const data = await binaryFromAsset(asset, requestFetch);
const mimeType = audioMimeType(asset);
const filename = asset.name || "audio.webm";
const form = new FormData();
form.append("model", config.sttModel);
form.append("file", new Blob([data], { type: mimeType }), filename);
const response = await requestFetch(transcriptionEndpoint(config.baseUrl), {
method: "POST",
headers: {
authorization: `Bearer ${config.key}`,
"user-agent": "codex-market",
},
body: form,
});
if (!response.ok) {
const detail = (await response.text().catch(() => "")).slice(0, 300);
throw new Error(`音频转写失败(HTTP ${response.status}${detail ? `:${detail}` : ""})`);
}
const result = await response.json();
const segments = Array.isArray(result.segments) ? result.segments : [];
const items = segments.map((segment) => ({
kind: "transcript",
text: String(segment.text || "").trim(),
start_ms: Number.isFinite(Number(segment.start)) ? Math.round(Number(segment.start) * 1000) : null,
end_ms: Number.isFinite(Number(segment.end)) ? Math.round(Number(segment.end) * 1000) : null,
confidence: null,
})).filter((item) => item.text);
return {
summary: String(result.text || items.map((item) => item.text).join(" ")).slice(0, MAX_PDF_TEXT_LENGTH),
items,
};
}
async function understandImages(images, instruction, config, requestFetch) {
const content = [
{ type: "text", text: `用户问题:${String(instruction || "请识别附件内容").slice(0, 4000)}` },
...images.map((asset) => ({ type: "image_url", image_url: { url: asset.value } })),
];
const response = await requestFetch(chatEndpoint(config.baseUrl), {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${config.key}`,
"user-agent": "codex-market",
},
body: JSON.stringify({
model: config.model,
temperature: 0.1,
max_tokens: 1800,
stream: false,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content },
],
}),
});
if (!response.ok) {
const detail = (await response.text().catch(() => "")).slice(0, 300);
throw new Error(`MiniMax 识别失败(HTTP ${response.status}${detail ? `:${detail}` : ""})`);
}
return parseEvidence(extractContentText(await response.json()));
}
function evidenceText(evidence) {
return `[TD 多模态识别证据]\n${JSON.stringify(evidence)}`;
}
function appendEvidence(request, evidence) {
if (!Array.isArray(request.input)) return;
let target = null;
for (let i = request.input.length - 1; i >= 0; i -= 1) {
const item = request.input[i];
if (item && item.type === "message" && item.role === "user" && Array.isArray(item.content)) {
target = item;
break;
}
}
if (!target) {
target = { type: "message", role: "user", content: [] };
request.input.push(target);
}
target.content.push({ type: "input_text", text: evidenceText(evidence) });
}
function cleanupCache(now) {
for (const [key, value] of cache) if (now - value.at > CACHE_TTL_MS) cache.delete(key);
}
async function enrichResponsesRequest(request, { instruction = "", fetchImpl = global.fetch } = {}) {
const config = gatewayConfig();
const attachments = collectAttachments(request);
if (!attachments.length) return { processed: false, attachments: [] };
const imageAssets = attachments
.filter((asset) => asset.kind === "image" && asset.value && asset.value.length <= MAX_IMAGE_VALUE_LENGTH)
.slice(0, MAX_IMAGES_PER_REQUEST);
const evidence = {
version: 1,
provider: "minimax",
modality: attachments.length > 1 ? "mixed" : attachments[0].kind,
summary: "",
items: [],
unsupported: [],
};
const summaries = [];
if (imageAssets.length) {
if (!config.key) {
summaries.push("已检测到图片附件,但免费多模态网关尚未注入访问密钥,不能识别图片内容。不要声称已查看或识别该图片。");
} else {
cleanupCache(Date.now());
const cacheKey = sha256(`${config.model}:${instruction}:${imageAssets.map((asset) => asset.id).join(",")}`);
const cached = cache.get(cacheKey);
if (cached) {
summaries.push(cached.value.summary);
evidence.items.push(...cached.value.items);
} else {
try {
const understood = await understandImages(imageAssets, instruction, config, fetchImpl);
summaries.push(understood.summary);
evidence.items.push(...understood.items);
cache.set(cacheKey, { at: Date.now(), value: understood });
} catch (error) {
summaries.push(String((error && error.message) || "MiniMax 图片识别失败"));
}
}
}
}
for (const asset of attachments.filter((item) => item.kind === "document")) {
try {
const understood = await understandPdf(asset, fetchImpl);
summaries.push(understood.summary);
evidence.items.push(...understood.items);
} catch (error) {
evidence.unsupported.push({ kind: "document", name: asset.name || "未命名附件" });
summaries.push(String((error && error.message) || "PDF 解析失败"));
}
}
for (const asset of attachments.filter((item) => item.kind === "audio")) {
if (!config.key || !config.sttModel) {
evidence.unsupported.push({ kind: "audio", name: asset.name || "未命名附件" });
summaries.push("音频已检测到,但免费多模态网关尚未配置音频转写模型。");
continue;
}
try {
const understood = await understandAudio(asset, config, fetchImpl);
summaries.push(understood.summary);
evidence.items.push(...understood.items);
} catch (error) {
summaries.push(String((error && error.message) || "音频转写失败"));
}
}
evidence.summary = summaries.filter(Boolean).join("\n").slice(0, MAX_PDF_TEXT_LENGTH);
appendEvidence(request, evidence);
return { processed: true, attachments, evidence };
}
module.exports = {
collectAttachments,
enrichResponsesRequest,
parseEvidence,
};