| import path from "node:path"; |
| import { fileURLToPath } from "node:url"; |
| import type { ImageContent } from "@mariozechner/pi-ai"; |
| import { resolveUserPath } from "../../../utils.js"; |
| import { loadWebMedia } from "../../../web/media.js"; |
| import type { ImageSanitizationLimits } from "../../image-sanitization.js"; |
| import { |
| createSandboxBridgeReadFile, |
| resolveSandboxedBridgeMediaPath, |
| } from "../../sandbox-media-paths.js"; |
| import { assertSandboxPath } from "../../sandbox-paths.js"; |
| import type { SandboxFsBridge } from "../../sandbox/fs-bridge.js"; |
| import { sanitizeImageBlocks } from "../../tool-images.js"; |
| import { log } from "../logger.js"; |
|
|
| |
| |
| |
| const IMAGE_EXTENSION_NAMES = [ |
| "png", |
| "jpg", |
| "jpeg", |
| "gif", |
| "webp", |
| "bmp", |
| "tiff", |
| "tif", |
| "heic", |
| "heif", |
| ] as const; |
| const IMAGE_EXTENSIONS = new Set(IMAGE_EXTENSION_NAMES.map((ext) => `.${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 PATH_REGEX_SOURCE = |
| "(?:^|\\s|[\"'`(])((\\.\\.?/|[~/])[^\\s\"'`()\\[\\]]*\\.(?:" + IMAGE_EXTENSION_PATTERN + "))"; |
|
|
| |
| |
| |
| export interface DetectedImageRef { |
| |
| raw: string; |
| |
| type: "path"; |
| |
| resolved: string; |
| } |
|
|
| |
| |
| |
| function isImageExtension(filePath: string): boolean { |
| const ext = path.extname(filePath).toLowerCase(); |
| return IMAGE_EXTENSIONS.has(ext); |
| } |
|
|
| function normalizeRefForDedupe(raw: string): string { |
| return process.platform === "win32" ? raw.toLowerCase() : raw; |
| } |
|
|
| async function sanitizeImagesWithLog( |
| images: ImageContent[], |
| label: string, |
| imageSanitization?: ImageSanitizationLimits, |
| ): Promise<ImageContent[]> { |
| 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; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function detectImageReferences(prompt: string): DetectedImageRef[] { |
| const refs: DetectedImageRef[] = []; |
| const seen = new Set<string>(); |
|
|
| |
| const addPathRef = (raw: string) => { |
| 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; |
| } |
| seen.add(dedupeKey); |
| const resolved = trimmed.startsWith("~") ? resolveUserPath(trimmed) : trimmed; |
| refs.push({ raw: trimmed, type: "path", resolved }); |
| }; |
|
|
| |
| |
| |
| const mediaAttachedPattern = /\[media attached(?:\s+\d+\/\d+)?:\s*([^\]]+)\]/gi; |
| const mediaAttachedPathPattern = new RegExp(MEDIA_ATTACHED_PATH_REGEX_SOURCE, "i"); |
| const messageImagePattern = new RegExp(MESSAGE_IMAGE_REGEX_SOURCE, "gi"); |
| const fileUrlPattern = new RegExp(FILE_URL_REGEX_SOURCE, "gi"); |
| const pathPattern = new RegExp(PATH_REGEX_SOURCE, "gi"); |
| let match: RegExpExecArray | null; |
| while ((match = mediaAttachedPattern.exec(prompt)) !== null) { |
| const content = match[1]; |
|
|
| |
| if (/^\d+\s+files?$/i.test(content.trim())) { |
| continue; |
| } |
|
|
| |
| |
| |
| |
| const pathMatch = content.match(mediaAttachedPathPattern); |
| if (pathMatch?.[1]) { |
| addPathRef(pathMatch[1].trim()); |
| } |
| } |
|
|
| |
| while ((match = messageImagePattern.exec(prompt)) !== null) { |
| const raw = match[1]?.trim(); |
| if (raw) { |
| addPathRef(raw); |
| } |
| } |
|
|
| |
|
|
| |
| while ((match = fileUrlPattern.exec(prompt)) !== null) { |
| const raw = match[0]; |
| const dedupeKey = normalizeRefForDedupe(raw); |
| if (seen.has(dedupeKey)) { |
| continue; |
| } |
| seen.add(dedupeKey); |
| |
| try { |
| const resolved = fileURLToPath(raw); |
| refs.push({ raw, type: "path", resolved }); |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| while ((match = pathPattern.exec(prompt)) !== null) { |
| |
| if (match[1]) { |
| addPathRef(match[1]); |
| } |
| } |
|
|
| return refs; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export async function loadImageFromRef( |
| ref: DetectedImageRef, |
| workspaceDir: string, |
| options?: { |
| maxBytes?: number; |
| workspaceOnly?: boolean; |
| sandbox?: { root: string; bridge: SandboxFsBridge }; |
| }, |
| ): Promise<ImageContent | null> { |
| try { |
| let targetPath = ref.resolved; |
|
|
| |
| if (options?.sandbox) { |
| try { |
| const resolved = await resolveSandboxedBridgeMediaPath({ |
| sandbox: { |
| root: options.sandbox.root, |
| bridge: options.sandbox.bridge, |
| workspaceOnly: options.workspaceOnly, |
| }, |
| mediaPath: targetPath, |
| }); |
| targetPath = resolved.resolved; |
| } catch (err) { |
| log.debug( |
| `Native image: sandbox validation failed for ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`, |
| ); |
| return null; |
| } |
| } else if (!path.isAbsolute(targetPath)) { |
| targetPath = path.resolve(workspaceDir, targetPath); |
| } |
| if (options?.workspaceOnly && !options?.sandbox) { |
| const root = options?.sandbox?.root ?? workspaceDir; |
| await assertSandboxPath({ |
| filePath: targetPath, |
| cwd: root, |
| root, |
| }); |
| } |
|
|
| |
| const media = options?.sandbox |
| ? await loadWebMedia(targetPath, { |
| maxBytes: options.maxBytes, |
| sandboxValidated: true, |
| readFile: createSandboxBridgeReadFile({ sandbox: options.sandbox }), |
| }) |
| : await loadWebMedia(targetPath, 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"; |
| const data = media.buffer.toString("base64"); |
|
|
| return { type: "image", data, mimeType }; |
| } catch (err) { |
| |
| log.debug( |
| `Native image: failed to load ${ref.resolved}: ${err instanceof Error ? err.message : String(err)}`, |
| ); |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function modelSupportsImages(model: { input?: string[] }): boolean { |
| return model.input?.includes("image") ?? false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function detectAndLoadPromptImages(params: { |
| prompt: string; |
| workspaceDir: string; |
| model: { input?: string[] }; |
| existingImages?: ImageContent[]; |
| maxBytes?: number; |
| maxDimensionPx?: number; |
| workspaceOnly?: boolean; |
| sandbox?: { root: string; bridge: SandboxFsBridge }; |
| }): Promise<{ |
| |
| images: ImageContent[]; |
| detectedRefs: DetectedImageRef[]; |
| loadedCount: number; |
| skippedCount: number; |
| }> { |
| |
| if (!modelSupportsImages(params.model)) { |
| return { |
| images: [], |
| detectedRefs: [], |
| loadedCount: 0, |
| skippedCount: 0, |
| }; |
| } |
|
|
| |
| const allRefs = detectImageReferences(params.prompt); |
|
|
| if (allRefs.length === 0) { |
| return { |
| images: params.existingImages ?? [], |
| detectedRefs: [], |
| loadedCount: 0, |
| skippedCount: 0, |
| }; |
| } |
|
|
| log.debug(`Native image: detected ${allRefs.length} image refs in prompt`); |
|
|
| const promptImages: ImageContent[] = [...(params.existingImages ?? [])]; |
|
|
| let loadedCount = 0; |
| let skippedCount = 0; |
|
|
| for (const ref of allRefs) { |
| const image = await loadImageFromRef(ref, params.workspaceDir, { |
| maxBytes: params.maxBytes, |
| workspaceOnly: params.workspaceOnly, |
| sandbox: params.sandbox, |
| }); |
| if (image) { |
| promptImages.push(image); |
| loadedCount++; |
| log.debug(`Native image: loaded ${ref.type} ${ref.resolved}`); |
| } else { |
| skippedCount++; |
| } |
| } |
|
|
| const imageSanitization: ImageSanitizationLimits = { |
| maxDimensionPx: params.maxDimensionPx, |
| }; |
| const sanitizedPromptImages = await sanitizeImagesWithLog( |
| promptImages, |
| "prompt:images", |
| imageSanitization, |
| ); |
|
|
| return { |
| images: sanitizedPromptImages, |
| detectedRefs: allRefs, |
| loadedCount, |
| skippedCount, |
| }; |
| } |
|
|