UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

309 lines (308 loc) • 13.7 kB
import { r as formatUncaughtError, t as formatErrorMessage } from "./errors-O33D01XW.mjs"; import { n as resolveRuntimeProcessEntrypointUrl } from "./runtime-process-url-D3H3_asn.mjs"; import { r as WorkerTaskError } from "./src-WCJCjrey.mjs"; import { t as WorkerTaskPool } from "./worker-task-pool-iI3pfrsK.mjs"; import { r as MAX_IMAGE_BYTES } from "./constants-GzjKtFvA.mjs"; import { d as normalizeMimeType, l as kindFromMime, r as extensionForMime } from "./mime-CGDYrRzL.mjs"; import { t as probeMediaFilesWithinBudget } from "./media-probe-COHtJCIk.mjs"; import { n as isGenericContainerMime, r as prepareAttachment } from "./attachment-processor.runtime.js"; import { r as parseInboundMediaUri } from "./inbound-media-uri-Bc4RWtcM.mjs"; import { f as saveMediaBuffer, i as deleteMediaBuffer } from "./store-CP-GWHOW.mjs"; import { n as registerMediaCleanupDrain } from "./server-media-cleanup-lifecycle-KlRv-aNy.mjs"; import { t as SessionMutationAuthorizationChangedError } from "./session-mutation-authorization-error-CCz8QGWh.mjs"; import "./chat-attachment-policy-BBDKPSK_.mjs"; import { t as formatForLog } from "./ws-log-Lc1qgHtV.mjs"; //#region src/media/attachment-processor.ts const pool = new WorkerTaskPool({ workerUrl: resolveRuntimeProcessEntrypointUrl("attachmentProcessor"), maxWorkers: 2, sharedCompute: true }); async function prepareMediaAttachment(input, maxBytes, signal) { signal?.throwIfAborted(); const size = Math.floor(input.base64.length * 3 / 4) - (input.base64.endsWith("==") ? 2 : input.base64.endsWith("=") ? 1 : 0); if (size > maxBytes) throw new Error(`attachment ${input.label}: exceeds size limit (${size} > ${maxBytes} bytes)`); const result = input.base64.length < 262144 ? await prepareAttachment(input) : await pool.run(input, { inputBytes: input.base64.length * 2, signal, timeoutMs: 18e4 }); signal?.throwIfAborted(); return result; } //#endregion //#region src/gateway/chat-attachments.ts /** Deletes prepared inbound files that never reached a durable owner. */ async function discardPreparedInboundMedia(refs, log) { const deletion = Promise.allSettled(refs.map((ref) => deleteMediaBuffer(ref.id, "inbound"))); registerMediaCleanupDrain(deletion.then(() => void 0)); const results = await deletion; for (const [index, result] of results.entries()) if (result.status === "rejected" && log) log.warn(`failed to discard prepared inbound media ${refs[index]?.id}: ${formatErrorMessage(result.reason)}`); } const INLINE_IMAGE_DURABLE_OMISSION_MARKER = "[image attachment omitted: durable managed media claim unavailable]"; const TEXT_ONLY_OFFLOAD_LIMIT = 10; const MAX_CHAT_ATTACHMENT_MEDIA_PROBES = 8; const CHAT_ATTACHMENT_MEDIA_PROBE_CONCURRENCY = 2; const CHAT_ATTACHMENT_MEDIA_PROBE_BUDGET_MS = 3e3; async function enrichOffloadedMediaMetadata(refs) { const candidates = refs.flatMap((ref) => { const kind = kindFromMime(ref.mimeType); return kind === "audio" || kind === "video" ? [{ kind, ref }] : []; }); const metadata = await probeMediaFilesWithinBudget(candidates.map(({ kind, ref }) => ({ filePath: ref.path, kind })), { budgetMs: CHAT_ATTACHMENT_MEDIA_PROBE_BUDGET_MS, concurrency: CHAT_ATTACHMENT_MEDIA_PROBE_CONCURRENCY, maxProbes: MAX_CHAT_ATTACHMENT_MEDIA_PROBES }); for (const [index, candidate] of candidates.entries()) Object.assign(candidate.ref, metadata[index]); } function logAttachmentFailure(log, label, err) { const primary = formatUncaughtError(err); const cause = err instanceof Error ? err.cause : void 0; const causeText = cause === void 0 ? "" : formatUncaughtError(cause); log.error(label, { error: !causeText || causeText === primary ? primary : `${primary}\nCaused by: ${causeText}`, consoleMessage: `${label}: ${formatForLog(err)}` }); } function stripImageMediaMarkers(message, refs) { return refs.reduce((projected, ref) => { const marker = ref.mimeType.startsWith("image/") ? `\n[media attached: ${ref.mediaRef}]` : ""; const index = marker ? projected.lastIndexOf(marker) : -1; return index < 0 ? projected : projected.slice(0, index) + projected.slice(index + marker.length); }, message); } async function persistInboundImagesForTranscript(params) { const entries = []; let omission = "none"; try { params.assertCurrent?.(); for (const image of params.images) try { params.assertCurrent?.(); const saved = await saveMediaBuffer(Buffer.from(image.data, "base64"), image.mimeType, "inbound", void 0, image.fileName, void 0, { assertCommitAllowed: params.assertCurrent }); entries.push({ id: saved.id, path: saved.path, sourceIndex: image.sourceIndex, imageKind: "inline", fact: { url: buildManagedInboundMediaRef(saved.id), contentType: saved.contentType ?? image.mimeType, kind: "image", ...image.fileName ? { fileName: image.fileName } : {}, sizeBytes: saved.size } }); } catch (err) { if (err instanceof SessionMutationAuthorizationChangedError) throw err; params.assertCurrent?.(); omission = "inline-image-save-failed"; params.log.warn(`${params.logContext}: failed to persist inbound image (${image.mimeType}): ${formatErrorMessage(err)}`); } params.assertCurrent?.(); } catch (error) { await discardPreparedInboundMedia(entries, params.log); throw error; } for (const ref of params.offloadedRefs) { const fact = { url: buildManagedInboundMediaRef(ref.id), contentType: ref.mimeType, kind: ref.kind, fileName: ref.label, ...ref.origin ? { origin: ref.origin } : {}, sizeBytes: ref.sizeBytes, ...ref.durationMs !== void 0 ? { durationMs: ref.durationMs } : {}, ...ref.width !== void 0 ? { width: ref.width } : {}, ...ref.height !== void 0 ? { height: ref.height } : {}, ...ref.mimeType.startsWith("image/") ? {} : { hydrationSuppressed: true } }; entries.push({ id: ref.id, path: ref.path, sourceIndex: ref.sourceIndex, ...ref.mimeType.startsWith("image/") ? { imageKind: "offloaded" } : {}, fact }); } entries.sort((left, right) => left.sourceIndex - right.sourceIndex); return { entries, omission }; } var UnsupportedAttachmentError = class extends Error { constructor(reason, message) { super(message); this.name = "UnsupportedAttachmentError"; this.reason = reason; } }; var MediaOffloadError = class extends Error { constructor(message, options) { super(message, options); this.name = "MediaOffloadError"; this.cause = options?.cause; } }; function ensureExtension(label, mime) { if (/\.[a-zA-Z0-9]+$/.test(label)) return label; const ext = extensionForMime(mime) ?? ""; return ext ? `${label}${ext}` : label; } function buildManagedInboundMediaRef(id) { const candidate = `media://inbound/${id}`; const parsed = parseInboundMediaUri(candidate); if (!parsed || parsed.id !== id) throw new Error("Saved media ID failed canonical validation"); return parsed.normalizedSource; } function normalizeAttachment(att, idx) { const mime = att.mimeType ?? ""; const content = att.content; const label = att.fileName || att.type || `attachment-${idx + 1}`; if (typeof content !== "string") throw new Error(`attachment ${label}: content must be base64 string`); let base64 = content.trim(); const commaIndex = base64.startsWith("data:") ? base64.indexOf(",") : -1; if (commaIndex >= 0 && /^data:[^;,]+;base64$/.test(base64.slice(0, commaIndex))) base64 = base64.slice(commaIndex + 1); return { label, mime, base64 }; } async function parseMessageWithAttachments(message, attachments, opts) { const maxBytes = opts?.maxBytes ?? 20971520; const log = opts?.log; const supportsInlineImages = opts?.supportsInlineImages !== false; const acceptNonImage = opts?.acceptNonImage !== false; const supportsImagesOption = opts?.supportsImages; let resolvedSupportsImages = typeof supportsImagesOption === "boolean" ? supportsImagesOption : void 0; const resolveSupportsImages = async () => { if (resolvedSupportsImages !== void 0) return resolvedSupportsImages; resolvedSupportsImages = typeof supportsImagesOption === "function" ? await supportsImagesOption() : true; return resolvedSupportsImages; }; if (!attachments || attachments.length === 0) return { message, images: [], imageOrder: [], media: [], offloadedRefs: [] }; const images = []; const imageOrder = []; const offloadedRefs = []; let updatedMessage = message; let textOnlyImageOffloadCount = 0; const savedMediaIds = []; try { opts?.assertCurrent?.(); for (const [idx, att] of attachments.entries()) { if (!att) continue; const { base64: b64, label, mime } = normalizeAttachment(att, idx); if (b64.length === 0) throw new UnsupportedAttachmentError("empty-payload", `attachment ${label}: empty payload`); const prepared = await prepareMediaAttachment({ base64: b64, label, mime, imageStorage: opts?.imageStorage }, maxBytes, opts?.signal); const { sizeBytes, mime: finalMime } = prepared; const providedMime = normalizeMimeType(mime); if (providedMime && !isGenericContainerMime(providedMime) && finalMime !== providedMime) log?.warn(`attachment ${label}: mime mismatch (${providedMime} -> ${finalMime})`); const isImage = finalMime.startsWith("image/"); const shouldForceImageOffload = isImage && !await resolveSupportsImages(); opts?.signal?.throwIfAborted(); opts?.assertCurrent?.(); if (isImage && !supportsInlineImages && !shouldForceImageOffload) throw new UnsupportedAttachmentError("text-only-image", `attachment ${label}: active model does not accept image inputs`); if (!isImage && !acceptNonImage) throw new UnsupportedAttachmentError("unsupported-non-image", `attachment ${label}: non-image attachments (${finalMime}) are not supported on this entrypoint`); if (isImage && sizeBytes > 6291456) throw new Error(`attachment ${label}: image exceeds size limit (${sizeBytes} > ${MAX_IMAGE_BYTES} bytes)`); if (shouldForceImageOffload && textOnlyImageOffloadCount >= TEXT_ONLY_OFFLOAD_LIMIT) { log?.warn(`attachment ${label}: dropping image because text-only offload limit ${TEXT_ONLY_OFFLOAD_LIMIT} was reached`); updatedMessage += "\n[image attachment omitted: text-only attachment limit reached]"; continue; } if (!(shouldForceImageOffload || !isImage || opts?.imageStorage !== "inline" && sizeBytes > 2e6)) { images.push({ type: "image", data: b64, mimeType: finalMime, ...att.fileName ? { fileName: att.fileName } : {}, sourceIndex: idx }); imageOrder.push("inline"); continue; } opts?.assertCurrent?.(); const bytes = prepared.buffer; const buffer = bytes ? Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) : Buffer.from(b64, "base64"); let savedMedia; let mediaRef; try { const labelWithExt = ensureExtension(label, finalMime); savedMedia = await saveMediaBuffer(buffer, finalMime, "inbound", maxBytes, labelWithExt, void 0, { assertCommitAllowed: opts?.assertCurrent }); mediaRef = buildManagedInboundMediaRef(savedMedia.id); } catch (err) { if (err instanceof SessionMutationAuthorizationChangedError) throw err; opts?.assertCurrent?.(); throw new MediaOffloadError(`[Gateway Error] Failed to save intercepted media to disk: ${formatErrorMessage(err)}`, { cause: err }); } savedMediaIds.push(savedMedia.id); updatedMessage += `\n[media attached: ${mediaRef}]`; log?.info?.(shouldForceImageOffload ? `[Gateway] Offloaded image for text-only model. Saved: ${mediaRef}` : `[Gateway] Offloaded attachment (${finalMime}). Saved: ${mediaRef}`); offloadedRefs.push({ mediaRef, id: savedMedia.id, path: savedMedia.path, kind: kindFromMime(finalMime) ?? "unknown", mimeType: finalMime, label, sizeBytes, sourceIndex: idx, ...att.origin === "paste" || att.origin === "file" ? { origin: att.origin } : {}, ...typeof att.durationMs === "number" && Number.isFinite(att.durationMs) && att.durationMs >= 0 ? { durationMs: att.durationMs } : {}, ...typeof att.width === "number" && Number.isFinite(att.width) && att.width >= 0 ? { width: att.width } : {}, ...typeof att.height === "number" && Number.isFinite(att.height) && att.height >= 0 ? { height: att.height } : {} }); if (isImage) { imageOrder.push("offloaded"); if (shouldForceImageOffload) textOnlyImageOffloadCount++; } } await enrichOffloadedMediaMetadata(offloadedRefs); opts?.assertCurrent?.(); } catch (err) { if (savedMediaIds.length > 0) await Promise.allSettled(savedMediaIds.map((id) => deleteMediaBuffer(id, "inbound"))); if (err instanceof WorkerTaskError && err !== opts?.signal?.reason) { if (err.code === "failed") throw new Error(err.message, { cause: err }); throw new MediaOffloadError(`[Gateway Error] Failed to prepare attachments: ${formatErrorMessage(err)}`, { cause: err }); } throw err; } return { message: updatedMessage !== message ? updatedMessage.trimEnd() : message, images, imageOrder, media: offloadedRefs.map((ref) => ({ path: ref.path, url: ref.mediaRef, contentType: ref.mimeType, kind: ref.kind, fileName: ref.label, ...ref.origin ? { origin: ref.origin } : {}, sizeBytes: ref.sizeBytes, ...ref.durationMs ? { durationMs: ref.durationMs } : {}, ...ref.width ? { width: ref.width } : {}, ...ref.height ? { height: ref.height } : {} })), offloadedRefs }; } //#endregion export { logAttachmentFailure as a, stripImageMediaMarkers as c, discardPreparedInboundMedia as i, MediaOffloadError as n, parseMessageWithAttachments as o, UnsupportedAttachmentError as r, persistInboundImagesForTranscript as s, INLINE_IMAGE_DURABLE_OMISSION_MARKER as t };