UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

427 lines (426 loc) 16.1 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { i as formatErrorMessage } from "./errors-BXgSefBE.js"; import { S as safeFileURLToPath, v as assertNoWindowsNetworkPath } from "./fs-safe-aqmM_n6V.js"; import { p as resolveUserPath } from "./utils-CCC-BEJH.js"; import "./local-file-access-CBe_wA_B.js"; import { a as resolveMediaReferenceLocalPath } from "./media-reference-C05KIYAZ.js"; import { t as log } from "./logger-B8odRltZ.js"; import { n as sanitizeImageBlocks } from "./tool-images-Du5Ml63i.js"; import { n as loadWebMedia } from "./web-media-D8G2d_q7.js"; import { n as resolveSandboxedBridgeMediaPath, t as createSandboxBridgeReadFile } from "./sandbox-media-paths-C5D4yKth.js"; import path from "node:path"; //#region src/agents/embedded-agent-runner/run/images.ts /** * Detects, resolves, and loads prompt image references for model input. */ /** * Common image file extensions for detection. */ const IMAGE_EXTENSION_NAMES = [ "png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif", "heic", "heif" ]; const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set(); for (const ext of IMAGE_EXTENSION_NAMES) IMAGE_EXTENSIONS.add(`.${ext}`); const IMAGE_EXTENSION_PATTERN = IMAGE_EXTENSION_NAMES.join("|"); const MEDIA_ATTACHED_PATH_REGEX_SOURCE = "^\\s*(.+?\\.(?:" + IMAGE_EXTENSION_PATTERN + "))\\s*(?:\\(|$|\\|)"; const MESSAGE_IMAGE_REGEX_SOURCE = "\\[Image:\\s*source:\\s*([^\\]]+\\.(?:" + IMAGE_EXTENSION_PATTERN + "))\\]"; const FILE_URL_REGEX_SOURCE = "file://[^\\s<>\"'`\\]]+\\.(?:" + IMAGE_EXTENSION_PATTERN + ")"; const WINDOWS_DRIVE_PATH_REGEX_SOURCE = "(?:^|\\s|[\"'`(])([A-Za-z]:[\\\\/][^\\s\"'`()\\[\\]]*\\.(?:" + IMAGE_EXTENSION_PATTERN + "))"; const PATH_REGEX_SOURCE = "(?:^|\\s|[\"'`(])((\\.\\.?/|[~/])[^\\s\"'`()\\[\\]]*\\.(?:" + IMAGE_EXTENSION_PATTERN + "))"; const MEDIA_ATTACHED_PATTERN = /\[media attached(?:\s+\d+\/\d+)?:\s*([^\]]+)\]/gi; const MEDIA_ATTACHED_PATH_PATTERN = new RegExp(MEDIA_ATTACHED_PATH_REGEX_SOURCE, "i"); const MESSAGE_IMAGE_PATTERN = new RegExp(MESSAGE_IMAGE_REGEX_SOURCE, "gi"); const FILE_URL_PATTERN = new RegExp(FILE_URL_REGEX_SOURCE, "gi"); const WINDOWS_DRIVE_PATH_PATTERN = new RegExp(WINDOWS_DRIVE_PATH_REGEX_SOURCE, "gi"); const PATH_PATTERN = new RegExp(PATH_REGEX_SOURCE, "gi"); /** * Matches the opaque media URI written by the Gateway's claim-check offload: * media://inbound/<uuid-or-id> * * Uses an exclusion-based character class rather than a whitelist so that * Unicode filenames (e.g. Chinese characters) preserved by sanitizeFilename * in store.ts are matched correctly. * * Explicitly excluded from the ID segment: * ] — closes the surrounding [media attached: ...] bracket * \s — any whitespace (space, newline, tab) — terminates the token * / — forward slash path separator (traversal prevention) * \ — back slash path separator (traversal prevention) * \x00 — null byte (path injection prevention) * * resolveMediaBufferPath applies its own guards against these characters, but * excluding them here provides defence-in-depth at the parsing layer. * * Example valid IDs: * "1c77ce17-20b9-4546-be64-6e36a9adcb2c.png" * "photo---1c77ce17-20b9-4546-be64-6e36a9adcb2c.png" * "图片---1c77ce17-20b9-4546-be64-6e36a9adcb2c.png" */ const MEDIA_URI_REGEX = /\bmedia:\/\/inbound\/([^\]\s/\\]+)/; /** * Checks if a file extension indicates an image file. */ function isImageExtension(filePath) { const ext = normalizeLowercaseStringOrEmpty(path.extname(filePath)); return IMAGE_EXTENSIONS.has(ext); } function normalizeRefForDedupe(raw) { return process.platform === "win32" ? normalizeLowercaseStringOrEmpty(raw) : raw; } function isOpenClawCliImageCachePath(filePath) { const parts = filePath.replaceAll("\\", "/").split("/"); return parts.some((part, index) => { if (part === ".openclaw-cli-images") return true; const parent = parts[index - 1] ?? ""; return part === "openclaw-cli-images" && /^openclaw(?:-\d+)?$/.test(parent); }); } /** * Rebuilds the model image array in the same order the prompt saw them: * existing inline images and offloaded attachments follow `imageOrder`, then * explicit prompt path/media refs are appended after attachment-owned images. */ function mergePromptAttachmentImages(params) { const promptImages = []; const existingImages = params.existingImages ?? []; const offloadedImages = params.offloadedImages ?? []; if (params.imageOrder && params.imageOrder.length > 0) { let inlineIndex = 0; let offloadedIndex = 0; for (const entry of params.imageOrder) { if (entry === "inline") { const image = existingImages[inlineIndex++]; if (image) promptImages.push(image); continue; } const image = offloadedImages[offloadedIndex++]; if (image) promptImages.push(image); } while (inlineIndex < existingImages.length) promptImages.push(existingImages[inlineIndex++]); while (offloadedIndex < offloadedImages.length) { const image = offloadedImages[offloadedIndex++]; if (image) promptImages.push(image); } } else { promptImages.push(...existingImages); for (const image of offloadedImages) if (image) promptImages.push(image); } promptImages.push(...params.promptRefImages ?? []); return promptImages; } function createRefCountMap(refs) { const counts = /* @__PURE__ */ new Map(); for (const ref of refs) { const key = `${ref.type}\0${normalizeRefForDedupe(ref.resolved)}`; counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; } function consumeRefCount(counts, ref) { const key = `${ref.type}\0${normalizeRefForDedupe(ref.resolved)}`; const count = counts.get(key) ?? 0; if (count <= 0) return false; if (count === 1) counts.delete(key); else counts.set(key, count - 1); return true; } /** * Reads only the leading attachment boilerplate block. User-authored image refs * after the first blank/non-attachment line must remain prompt refs. */ function extractLeadingAttachmentPrompt(prompt) { const lines = prompt.split(/\r?\n/); const attachmentLines = []; for (const line of lines) { const trimmed = line.trim(); if (!trimmed) break; if (/^\[media attached:\s*\d+\s+files?\]$/i.test(trimmed)) { attachmentLines.push(trimmed); continue; } if (/^\[media attached(?:\s+\d+\/\d+)?:\s*[^\]]+\]$/i.test(trimmed)) { attachmentLines.push(trimmed); continue; } break; } return attachmentLines.join("\n"); } function extractLeadingInlineAttachmentRefs(prompt, count) { if (count <= 0) return []; const attachmentPrompt = extractLeadingAttachmentPrompt(prompt); if (!attachmentPrompt) return []; return detectImageReferences(attachmentPrompt).slice(0, count); } /** * Finds trailing media:// attachment lines produced by claim-check offload. The * reverse scan stops at the first non-attachment line so prompt text above it is * not accidentally treated as attachment boilerplate. */ function extractTrailingAttachmentMediaUris(prompt, count) { if (count <= 0) return []; const lines = prompt.split(/\r?\n/); const uris = []; for (let index = lines.length - 1; index >= 0 && uris.length < count; index--) { const line = lines[index]?.trim(); if (!line || line.includes("\0")) break; const match = line.match(/^\[media attached:\s*(media:\/\/inbound\/[^\]\s/\\]+)\]$/); if (!match?.[1]) break; uris.push(match[1]); } for (let left = 0, right = uris.length - 1; left < right; left += 1, right -= 1) { const uri = uris[left]; uris[left] = uris[right]; uris[right] = uri; } return uris; } /** * Separates image refs that came from attachment boilerplate from refs the user * actually typed into the prompt. Attachment refs are already represented by * existing/offloaded image content and should not be loaded a second time. */ function splitPromptAndAttachmentRefs(params) { const existingImageCount = params.existingImageCount ?? 0; const inlineOrderCount = params.imageOrder?.filter((entry) => entry === "inline").length; const inlineAttachmentRefCount = Math.min(existingImageCount, inlineOrderCount ?? existingImageCount); const inlineAttachmentRefs = createRefCountMap(extractLeadingInlineAttachmentRefs(params.prompt, inlineAttachmentRefCount)); const offloadedCount = params.imageOrder?.filter((entry) => entry === "offloaded").length ?? 0; const attachmentUris = new Set(offloadedCount > 0 ? extractTrailingAttachmentMediaUris(params.prompt, offloadedCount) : []); const promptRefs = []; const attachmentRefs = []; for (const ref of params.refs) { if (consumeRefCount(inlineAttachmentRefs, ref)) continue; if (ref.type === "media-uri" && attachmentUris.has(ref.resolved)) { attachmentRefs.push(ref); continue; } promptRefs.push(ref); } return { promptRefs, attachmentRefs }; } async function sanitizeImagesWithLog(images, label, imageSanitization) { const { images: sanitized, dropped } = await sanitizeImageBlocks(images, label, imageSanitization); if (dropped > 0) log.warn(`Native image: dropped ${dropped} image(s) after sanitization (${label}).`); return sanitized; } /** * Detects image references in a user prompt. * * Patterns detected: * - Absolute paths: /path/to/image.png * - Relative paths: ./image.png, ../images/photo.jpg * - Home paths: ~/Pictures/screenshot.png * - file:// URLs: file:///path/to/image.png * - Message attachments: [Image: source: /path/to/image.jpg] * - Gateway claim-check URIs: [media attached: media://inbound/<id>] * * @param prompt The user prompt text to scan * @returns Array of detected image references */ function detectImageReferences(prompt) { const refs = []; const seen = /* @__PURE__ */ new Set(); const addPathRef = (raw) => { const trimmed = raw.trim(); const dedupeKey = normalizeRefForDedupe(trimmed); if (!trimmed || seen.has(dedupeKey)) return; if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return; if (!isImageExtension(trimmed)) return; try { assertNoWindowsNetworkPath(trimmed, "Image path"); } catch { return; } const resolved = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed; if (isOpenClawCliImageCachePath(resolved)) return; seen.add(dedupeKey); refs.push({ raw: trimmed, type: "path", resolved }); }; MEDIA_ATTACHED_PATTERN.lastIndex = 0; MESSAGE_IMAGE_PATTERN.lastIndex = 0; FILE_URL_PATTERN.lastIndex = 0; WINDOWS_DRIVE_PATH_PATTERN.lastIndex = 0; PATH_PATTERN.lastIndex = 0; let match; while ((match = MEDIA_ATTACHED_PATTERN.exec(prompt)) !== null) { const content = match[1]; if (/^\d+\s+files?$/i.test(content.trim())) continue; const mediaUriMatch = content.match(MEDIA_URI_REGEX); if (mediaUriMatch && !mediaUriMatch[1].includes("\0")) { const uri = `media://inbound/${mediaUriMatch[1]}`; const dedupeKey = normalizeRefForDedupe(uri); if (!seen.has(dedupeKey)) { seen.add(dedupeKey); refs.push({ raw: uri, type: "media-uri", resolved: uri }); } continue; } const pathMatch = content.match(MEDIA_ATTACHED_PATH_PATTERN); if (pathMatch?.[1]) addPathRef(pathMatch[1].trim()); } while ((match = MESSAGE_IMAGE_PATTERN.exec(prompt)) !== null) { const raw = match[1]?.trim(); if (raw) addPathRef(raw); } while ((match = FILE_URL_PATTERN.exec(prompt)) !== null) { const raw = match[0]; const dedupeKey = normalizeRefForDedupe(raw); if (seen.has(dedupeKey)) continue; try { const resolved = safeFileURLToPath(raw); if (isOpenClawCliImageCachePath(resolved)) continue; seen.add(dedupeKey); refs.push({ raw, type: "path", resolved }); } catch {} } while ((match = WINDOWS_DRIVE_PATH_PATTERN.exec(prompt)) !== null) if (match[1]) addPathRef(match[1]); while ((match = PATH_PATTERN.exec(prompt)) !== null) if (match[1]) addPathRef(match[1]); return refs; } /** * Resolves and loads one detected image ref into model-ready image content. * Sandbox refs must validate through the bridge; non-sandbox refs can resolve * media claim-checks and workspace-relative paths before loadWebMedia enforces * local-root and size limits. */ async function loadImageFromRef(ref, workspaceDir, options) { try { let targetPath = ref.resolved; if (!options?.sandbox) targetPath = await resolveMediaReferenceLocalPath(targetPath); if (options?.sandbox) try { targetPath = (await resolveSandboxedBridgeMediaPath({ sandbox: { root: options.sandbox.root, bridge: options.sandbox.bridge, workspaceOnly: options.workspaceOnly }, mediaPath: targetPath, inboundFallbackDir: "media/inbound" })).resolved; } catch (err) { log.debug(`Native image: sandbox validation failed for ${ref.resolved}: ${formatErrorMessage(err)}`); return null; } else if (!path.isAbsolute(targetPath)) targetPath = path.resolve(workspaceDir, targetPath); const media = options?.sandbox ? await loadWebMedia(targetPath, { maxBytes: options.maxBytes, sandboxValidated: true, readFile: createSandboxBridgeReadFile({ sandbox: options.sandbox }) }) : await loadWebMedia(targetPath, options?.workspaceOnly ? { maxBytes: options.maxBytes, localRoots: options.localRoots ?? [workspaceDir] } : options?.maxBytes); if (media.kind !== "image") { log.debug(`Native image: not an image file: ${targetPath} (got ${media.kind})`); return null; } const mimeType = media.contentType ?? "image/jpeg"; return { type: "image", data: media.buffer.toString("base64"), mimeType }; } catch (err) { log.debug(`Native image: failed to load ${ref.resolved}: ${formatErrorMessage(err)}`); return null; } } /** Returns whether the resolved model advertises native image input support. */ function modelSupportsImages(model) { return model.input?.includes("image") ?? false; } /** * Detects, loads, orders, and sanitizes the image payload for one prompt turn. * Attachment boilerplate is separated from user-authored refs so existing * inline images and offloaded claim-check images are not loaded twice. */ async function detectAndLoadPromptImages(params) { if (!modelSupportsImages(params.model)) return { images: [], detectedRefs: [], loadedCount: 0, skippedCount: 0 }; const allRefs = detectImageReferences(params.prompt); if (allRefs.length === 0) return { images: await sanitizeImagesWithLog(params.existingImages ?? [], "prompt:images", { maxDimensionPx: params.maxDimensionPx }), detectedRefs: [], loadedCount: 0, skippedCount: 0 }; log.debug(`Native image: detected ${allRefs.length} image refs in prompt`); const { promptRefs, attachmentRefs } = splitPromptAndAttachmentRefs({ prompt: params.prompt, refs: allRefs, imageOrder: params.imageOrder, existingImageCount: params.existingImages?.length }); const promptRefImages = []; const offloadedImages = []; let loadedCount = 0; let skippedCount = 0; for (const ref of promptRefs) { const image = await loadImageFromRef(ref, params.workspaceDir, { maxBytes: params.maxBytes, workspaceOnly: params.workspaceOnly, localRoots: params.localRoots, sandbox: params.sandbox }); if (image) { promptRefImages.push(image); loadedCount++; log.debug(`Native image: loaded ${ref.type} ${ref.resolved}`); } else skippedCount++; } for (const ref of attachmentRefs) { const image = await loadImageFromRef(ref, params.workspaceDir, { maxBytes: params.maxBytes, workspaceOnly: params.workspaceOnly, localRoots: params.localRoots, sandbox: params.sandbox }); offloadedImages.push(image); if (image) { loadedCount++; log.debug(`Native image: loaded ${ref.type} ${ref.resolved}`); } else skippedCount++; } return { images: await sanitizeImagesWithLog(mergePromptAttachmentImages({ imageOrder: params.imageOrder, existingImages: params.existingImages, offloadedImages, promptRefImages }), "prompt:images", { maxDimensionPx: params.maxDimensionPx }), detectedRefs: allRefs, loadedCount, skippedCount }; } //#endregion export { modelSupportsImages as a, mergePromptAttachmentImages as i, detectImageReferences as n, splitPromptAndAttachmentRefs as o, loadImageFromRef as r, detectAndLoadPromptImages as t };