import type { ImageContent } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; import { type BlobPutResult, blobExtensionForImageMimeType } from "../session/blob-store"; import { fileHyperlink } from "../tui/hyperlink"; /** Matches `[Image #N]`/`[Image #N, WxH]` and `[Paste #N, +X lines]`/`[Paste #N, Y chars]` tokens. * Group 1 is the kind (`Image`/`Paste`), group 2 the 1-based index. The optional metadata * tail (`, …`) is captured loosely (no `]`/newline) so future label tweaks keep matching. */ export const PLACEHOLDER_REGEX = /\[(Image|Paste) #([1-9]\d*)(?:,[^\]\n]*)?\]/g; type ImageBlobWriter = (data: Buffer, options?: { extension?: string }) => Promise; type ImageBlobWriterSync = (data: Buffer, options?: { extension?: string }) => BlobPutResult; export type PlaceholderKind = "image" | "paste"; export interface PlaceholderRenderers { renderText: (text: string) => string; renderReference: (label: string, kind: PlaceholderKind, index: number) => string; } export function renderPlaceholders(text: string, renderers: PlaceholderRenderers): string { PLACEHOLDER_REGEX.lastIndex = 0; let result = ""; let last = 0; let matched = false; for (;;) { const match = PLACEHOLDER_REGEX.exec(text); if (match === null) break; matched = true; if (match.index > last) { result += renderers.renderText(text.slice(last, match.index)); } const kind: PlaceholderKind = match[1] === "Paste" ? "paste" : "image"; result += renderers.renderReference(match[0], kind, Number(match[2])); last = match.index + match[0].length; } if (!matched) { return renderers.renderText(text); } if (last < text.length) { result += renderers.renderText(text.slice(last)); } return result; } export function imageReferenceHyperlink( label: string, index: number, imageLinks: readonly (string | undefined)[] | undefined, renderLabel: (text: string) => string, ): string { const rendered = renderLabel(label); const target = imageLinks?.[index - 1]; return target ? fileHyperlink(target, rendered) : rendered; } async function materializeImageReferenceLinkAsync( image: ImageContent, index: number, putBlob: ImageBlobWriter, ): Promise { try { const result = await putBlob(Buffer.from(image.data, "base64"), { extension: blobExtensionForImageMimeType(image.mimeType), }); return result.displayPath; } catch (error) { logger.warn("Failed to write image reference blob", { index, mimeType: image.mimeType, error: error instanceof Error ? error.message : String(error), }); return undefined; } } function materializeImageReferenceLink( image: ImageContent, index: number, putBlob: ImageBlobWriterSync, ): string | undefined { try { const result = putBlob(Buffer.from(image.data, "base64"), { extension: blobExtensionForImageMimeType(image.mimeType), }); return result.displayPath; } catch (error) { logger.warn("Failed to write image reference blob", { index, mimeType: image.mimeType, error: error instanceof Error ? error.message : String(error), }); return undefined; } } export async function materializeImageReferenceLinks( images: readonly ImageContent[] | undefined, putBlob: ImageBlobWriter, ): Promise<(string | undefined)[] | undefined> { if (!images || images.length === 0) return undefined; const links = await Promise.all( images.map((image, index) => materializeImageReferenceLinkAsync(image, index + 1, putBlob)), ); return links.some(link => link !== undefined) ? links : undefined; } export function materializeImageReferenceLinksSync( images: readonly ImageContent[] | undefined, putBlob: ImageBlobWriterSync, ): (string | undefined)[] | undefined { if (!images || images.length === 0) return undefined; const links = images.map((image, index) => materializeImageReferenceLink(image, index + 1, putBlob)); return links.some(link => link !== undefined) ? links : undefined; }