124 lines
3.9 KiB
TypeScript
124 lines
3.9 KiB
TypeScript
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 };
|
|
});
|
|
}
|