import type { AttachedFileMeta, ContentBlock, RawMessage } from '@/types/chat'; const IMAGE_DATA_URL = /^data:(image\/[A-Za-z0-9.+-]+);base64,([A-Za-z0-9+/]+={0,2})$/iu; export interface StoredAttachmentForRestore { file: AttachedFileMeta; imageDataUrl?: string; } function contentBlocks(message: RawMessage): ContentBlock[] { return Array.isArray(message.content) ? message.content as ContentBlock[] : []; } export function getRestorableUserText(message: RawMessage | undefined): string { if (!message || message.role !== 'user') return ''; if (typeof message.content === 'string') return message.content; return contentBlocks(message) .filter((block) => block.type === 'text' && block.synthetic !== true) .map((block) => block.text ?? '') .filter((text) => text.length > 0) .join('\n\n') .trim(); } function isUndoable(message: RawMessage | undefined): message is RawMessage { return Boolean(message?.id && message.role === 'user'); } function cursorIndex(messages: readonly RawMessage[], revertMessageID?: string): number | null { if (!revertMessageID) return messages.length; const index = messages.findIndex((message) => message.id === revertMessageID); return index >= 0 ? index : null; } export function findUndoableUserMessage( messages: readonly RawMessage[], revertMessageID?: string, ): RawMessage | null { const limit = cursorIndex(messages, revertMessageID); if (limit === null) return null; for (let index = limit - 1; index >= 0; index -= 1) { if (isUndoable(messages[index])) return messages[index]; } return null; } export function findRedoUserMessage( messages: readonly RawMessage[], revertMessageID: string, ): RawMessage | null { const index = cursorIndex(messages, revertMessageID); if (index === null || index === messages.length) return null; for (let next = index + 1; next < messages.length; next += 1) { const message = messages[next]; if (message?.id && message.role === 'user') return message; } return null; } interface ValidatedImageDataUrl { dataUrl: string; mimeType: string; } function validatedImageDataUrl( value: string | null | undefined, ): ValidatedImageDataUrl | undefined { const candidate = value?.trim(); if (!candidate) return undefined; const match = IMAGE_DATA_URL.exec(candidate); if (!match || match[2].length % 4 !== 0) return undefined; return { dataUrl: candidate, mimeType: match[1].toLowerCase(), }; } function sameMimeType(left: string | undefined, right: string): boolean { return typeof left !== 'string' || left.trim().toLowerCase() === right.trim().toLowerCase(); } function normalizedMimeType(value: string): string { return value.trim().toLowerCase(); } function isImageMimeType(value: string): boolean { return normalizedMimeType(value).startsWith('image/'); } export function getStoredAttachmentsForRestore( message: RawMessage, ): StoredAttachmentForRestore[] { const imageBlocks = contentBlocks(message).filter((block) => block.type === 'image'); const files = message._attachedFiles ?? []; const imageFileCount = files.filter((file) => isImageMimeType(file.mimeType)).length; const canPairByPosition = imageBlocks.length === imageFileCount; let imageIndex = 0; return files.map((file) => { if (!isImageMimeType(file.mimeType)) return { file }; const block = imageBlocks[imageIndex]; imageIndex += 1; const preview = validatedImageDataUrl(file.preview); if (preview?.mimeType === normalizedMimeType(file.mimeType)) { return { file, imageDataUrl: preview.dataUrl }; } if (!canPairByPosition || !block) return { file }; if (block.alt && block.alt !== file.fileName) return { file }; const embedded = validatedImageDataUrl(block.url); if ( !embedded || embedded.mimeType !== normalizedMimeType(file.mimeType) || !sameMimeType(block.mimeType, embedded.mimeType) ) { return { file }; } return { file, imageDataUrl: embedded.dataUrl }; }); }