mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
LiveFiles: restructure, and store Handle to Attachment fragments
This commit is contained in:
@@ -10,6 +10,7 @@ import { createDMessageDataInlineText, createDocAttachmentFragment, DMessageAtta
|
||||
import type { AttachmentsDraftsStore } from './store-attachment-drafts-slice';
|
||||
import { AttachmentDraft, AttachmentDraftConverter, AttachmentDraftInput, AttachmentDraftSource, DraftEgoFragmentsInputData, draftInputMimeEgoFragments, draftInputMimeWebpage, DraftWebInputData } from './attachment.types';
|
||||
import { imageDataToImageAttachmentFragmentViaDBlob } from './attachment.dblobs';
|
||||
import { liveFileAddToAttachmentFragment, liveFileIsSupported } from '../livefile/livefile';
|
||||
|
||||
|
||||
// configuration
|
||||
@@ -261,11 +262,11 @@ export async function attachmentLoadInputAsync(source: Readonly<AttachmentDraftS
|
||||
/**
|
||||
* Defines the possible converters for an AttachmentDraft object based on its input type.
|
||||
*
|
||||
* @param {AttachmentDraftSource['media']} sourceType - The media type of the attachment source.
|
||||
* @param {Readonly<AttachmentDraftSource>} source - The source of the AttachmentDraft object.
|
||||
* @param {Readonly<AttachmentDraftInput>} input - The input of the AttachmentDraft object.
|
||||
* @param {(changes: Partial<AttachmentDraft>) => void} edit - A function to edit the AttachmentDraft object.
|
||||
*/
|
||||
export function attachmentDefineConverters(sourceType: AttachmentDraftSource['media'], input: Readonly<AttachmentDraftInput>, edit: (changes: Partial<Omit<AttachmentDraft, 'outputFragments'>>) => void) {
|
||||
export function attachmentDefineConverters(source: AttachmentDraftSource, input: Readonly<AttachmentDraftInput>, edit: (changes: Partial<Omit<AttachmentDraft, 'outputFragments'>>) => void) {
|
||||
|
||||
// return all the possible converters for the input
|
||||
const converters: AttachmentDraftConverter[] = [];
|
||||
@@ -275,7 +276,7 @@ export function attachmentDefineConverters(sourceType: AttachmentDraftSource['me
|
||||
// plain text types
|
||||
case PLAIN_TEXT_MIMETYPES.includes(input.mimeType):
|
||||
// handle a secondary layer of HTML 'text' origins: drop, paste, and clipboard-read
|
||||
const textOriginHtml = sourceType === 'text' && input.altMimeType === 'text/html' && !!input.altData;
|
||||
const textOriginHtml = source.media === 'text' && input.altMimeType === 'text/html' && !!input.altData;
|
||||
const isHtmlTable = !!input.altData?.startsWith('<table');
|
||||
|
||||
// p1: Tables
|
||||
@@ -283,7 +284,7 @@ export function attachmentDefineConverters(sourceType: AttachmentDraftSource['me
|
||||
converters.push({ id: 'rich-text-table', name: 'Markdown Table' });
|
||||
|
||||
// p2: Text
|
||||
converters.push({ id: 'text', name: 'Text' });
|
||||
converters.push({ id: 'text', name: liveFileIsSupported(source) ? 'Text (Live)' : 'Text' });
|
||||
|
||||
// p3: Html
|
||||
if (textOriginHtml) {
|
||||
@@ -470,7 +471,10 @@ export async function attachmentPerformConversion(
|
||||
// text as-is
|
||||
case 'text':
|
||||
const textData = createDMessageDataInlineText(inputDataToString(input.data), input.mimeType);
|
||||
newFragments.push(createDocAttachmentFragment(title, caption, 'text/plain', textData, refString, docMeta));
|
||||
const textDocAttachmentFragment = createDocAttachmentFragment(title, caption, 'text/plain', textData, refString, docMeta);
|
||||
// [LiveFile] if we have a handle for this file, transfer it to the Doc Attachment fragment
|
||||
liveFileAddToAttachmentFragment(source, textDocAttachmentFragment);
|
||||
newFragments.push(textDocAttachmentFragment);
|
||||
break;
|
||||
|
||||
// html as-is
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
|
||||
/// Preservation of Handles/Files during data transfers
|
||||
|
||||
/**
|
||||
* Extracts file system handles or files from a list of data transfer items.
|
||||
* Note: the main purpose of this function is to get all the files/handles upfront in a datatransfer,
|
||||
* as those objects expire with async operations.
|
||||
*/
|
||||
export function extractFileSystemHandlesOrFiles(items: DataTransferItemList) {
|
||||
const results: (File | Promise<FileSystemFileHandle | FileSystemDirectoryHandle | null>)[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind !== 'file')
|
||||
continue;
|
||||
|
||||
// extract file system handle if available
|
||||
if ('getAsFileSystemHandle' in item && typeof item.getAsFileSystemHandle === 'function') {
|
||||
try {
|
||||
const handle = item.getAsFileSystemHandle();
|
||||
if (handle)
|
||||
results.push(handle);
|
||||
continue;
|
||||
} catch (error) {
|
||||
console.error('Error getting file system handle:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// extract file if no handle available
|
||||
const file = item.getAsFile();
|
||||
if (file)
|
||||
results.push(file);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export function mightBeDirectory(file: File) {
|
||||
// Note: this doesn't even work, as Firefox reports directories with size > 0 on windows (e.g. 4096)
|
||||
return file.type === '' && file.size === 0;
|
||||
}
|
||||
|
||||
|
||||
/// Folder traversal
|
||||
|
||||
/**
|
||||
* Extending the `FileSystemDirectoryHandle` with a `values` method to iterate over the directory contents.
|
||||
* This is as defined in https://fs.spec.whatwg.org/#filesystemdirectoryhandle (File System Standard, Last Updated 28 June 2024).
|
||||
*/
|
||||
interface ExplorableFileSystemDirectoryHandle extends FileSystemDirectoryHandle {
|
||||
values?: () => AsyncIterable<FileSystemFileHandle | FileSystemDirectoryHandle | null>;
|
||||
}
|
||||
|
||||
interface FileWithHandleAndPath {
|
||||
fileWithHandle: FileWithHandle;
|
||||
relativeName: string;
|
||||
}
|
||||
|
||||
export async function getAllFilesFromDirectoryRecursively(directoryHandle: FileSystemDirectoryHandle): Promise<FileWithHandleAndPath[]> {
|
||||
const list: FileWithHandleAndPath[] = [];
|
||||
const separator = '/';
|
||||
|
||||
async function traverseDirectory(dirHandle: ExplorableFileSystemDirectoryHandle, path: string = '') {
|
||||
if ('values' in dirHandle && typeof dirHandle.values === 'function') {
|
||||
for await (const handle of dirHandle.values()) {
|
||||
if (!handle) continue;
|
||||
const relativePath = path ? `${path}${separator}${handle.name}` : handle.name;
|
||||
|
||||
if (handle.kind === 'file') {
|
||||
const fileWithHandle = await handle.getFile() as FileWithHandle;
|
||||
fileWithHandle.handle = handle;
|
||||
list.push({
|
||||
fileWithHandle: fileWithHandle,
|
||||
relativeName: relativePath,
|
||||
});
|
||||
} else if (handle.kind === 'directory') {
|
||||
await traverseDirectory(handle, relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await traverseDirectory(directoryHandle);
|
||||
return list;
|
||||
}
|
||||
|
||||
|
||||
/// Extraction of files names from a common prefix
|
||||
|
||||
export function extractFilePathsFromCommonRadix(fileURIs: string[]): string[] {
|
||||
|
||||
const filePaths = fileURIs
|
||||
.filter((path) => path.startsWith('file:'))
|
||||
.map((path) => path.slice(5));
|
||||
|
||||
if (filePaths.length < 2)
|
||||
return [];
|
||||
|
||||
const commonRadix = _findCommonPrefix(filePaths);
|
||||
if (!commonRadix.endsWith('/'))
|
||||
return [];
|
||||
|
||||
return filePaths.map((path) => path.slice(commonRadix.length));
|
||||
}
|
||||
|
||||
function _findCommonPrefix(strings: string[]) {
|
||||
if (!strings.length)
|
||||
return '';
|
||||
|
||||
const sortedStrings = strings.slice().sort();
|
||||
const firstString = sortedStrings[0];
|
||||
const lastString = sortedStrings[sortedStrings.length - 1];
|
||||
|
||||
let commonPrefix = '';
|
||||
for (let i = 0; i < firstString.length; i++) {
|
||||
if (firstString[i] === lastString[i]) {
|
||||
commonPrefix += firstString[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return commonPrefix;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export const createAttachmentDraftsStoreSlice: StateCreator<AttachmentsDraftsSto
|
||||
return;
|
||||
|
||||
// 2. Define the I->O Converters
|
||||
attachmentDefineConverters(source.media, loaded.input, editFn);
|
||||
attachmentDefineConverters(source, loaded.input, editFn);
|
||||
const defined = _getAttachment(attachmentDraftId);
|
||||
if (!defined?.converters.length)
|
||||
return;
|
||||
|
||||
@@ -9,11 +9,11 @@ import { getClipboardItems } from '~/common/util/clipboardUtils';
|
||||
import type { DConversationId } from '~/common/stores/chat/chat.conversation';
|
||||
import type { DMessageFragment } from '~/common/stores/chat/chat.fragments';
|
||||
import type { DMessageId } from '~/common/stores/chat/chat.message';
|
||||
import { getAllFilesFromDirectoryRecursively } from '~/common/livefile/filesystem-helpers';
|
||||
import { useChatAttachmentsStore } from '~/common/chats/store-chat-overlay';
|
||||
|
||||
import type { AttachmentDraftSourceOriginDTO, AttachmentDraftSourceOriginFile } from './attachment.types';
|
||||
import type { AttachmentDraftsStoreApi } from './store-attachment-drafts-slice';
|
||||
import { extractFilePathsFromCommonRadix, extractFileSystemHandlesOrFiles, getAllFilesFromDirectoryRecursively, mightBeDirectory } from './file-converters/filesystem-helpers';
|
||||
|
||||
|
||||
// enable to debug operations
|
||||
@@ -69,7 +69,7 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
// get the file items - note: important to not have any async/await or we'll lose the items of the data transfer
|
||||
const fileOrFSHandlePromises: (Promise<FileSystemFileHandle | FileSystemDirectoryHandle | null> | File)[] = heuristicBypassImage
|
||||
? [] /* special case: ignore images from Microsoft Office pastes (prioritize the HTML paste) */
|
||||
: extractFileSystemHandlesOrFiles(dt.items);
|
||||
: getDataTransferFilesOrPromises(dt.items);
|
||||
|
||||
// attach File(s)
|
||||
if (fileOrFSHandlePromises.length) {
|
||||
@@ -78,7 +78,7 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
let overrideFileNames: string[] = [];
|
||||
if (dt.types.includes('text/plain')) {
|
||||
const possiblePlainTextURIs: string[] = dt.getData('text/plain').split(/[\r\n]+/);
|
||||
overrideFileNames = extractFilePathsFromCommonRadix(possiblePlainTextURIs);
|
||||
overrideFileNames = mapFileURIsRemovingCommonRadix(possiblePlainTextURIs);
|
||||
if (overrideFileNames.length !== fileOrFSHandlePromises.length)
|
||||
overrideFileNames = [];
|
||||
else if (ATTACHMENTS_DEBUG_INTAKE)
|
||||
@@ -92,9 +92,12 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
// not provide a handle; if that's the case, we can't do anything, so we still add the file
|
||||
if (fileOrFSHandlePromise instanceof File) {
|
||||
const file = fileOrFSHandlePromise;
|
||||
if (mightBeDirectory(file)) {
|
||||
|
||||
// Directory detection from File objects weak or impossible - e.g. Firefox reports directories with size > 0 on windows (e.g. 4096)
|
||||
if (file.type === '' && file.size === 0) {
|
||||
console.warn('This browser does not support directories:', file);
|
||||
}
|
||||
|
||||
await attachAppendFile(method, file, overrideFileNames[fIdx]);
|
||||
continue;
|
||||
}
|
||||
@@ -264,4 +267,86 @@ export const useAttachmentDrafts = (attachmentsStoreApi: AttachmentDraftsStoreAp
|
||||
attachmentsTakeAllFragments,
|
||||
attachmentsTakeFragmentsByType,
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Extracts file system handles or files from a list of data transfer items.
|
||||
* Note: the main purpose of this function is to get all the files/handles **upfront** in a
|
||||
* datatransfer, as those objects expire with async operations.
|
||||
*/
|
||||
function getDataTransferFilesOrPromises(items: DataTransferItemList) {
|
||||
const results: (File | Promise<FileSystemFileHandle | FileSystemDirectoryHandle | null>)[] = [];
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind !== 'file')
|
||||
continue;
|
||||
|
||||
// extract file system handle if available
|
||||
if ('getAsFileSystemHandle' in item && typeof item.getAsFileSystemHandle === 'function') {
|
||||
try {
|
||||
const handle = item.getAsFileSystemHandle();
|
||||
if (handle)
|
||||
results.push(handle);
|
||||
continue;
|
||||
} catch (error) {
|
||||
console.error('Error getting file system handle:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// extract file if no handle available
|
||||
const file = item.getAsFile();
|
||||
if (file)
|
||||
results.push(file);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Maps a list of file URIs to relative paths, removing a common prefix.
|
||||
*
|
||||
* Example, takes the following files:
|
||||
* - file:///C:/Users/Me/Documents/MyFile1.txt
|
||||
* - file:///C:/Users/Me/Documents/Test/MyFile2.txt
|
||||
* - file:///C:/Users/Me/Documents/Test/MyFile3.txt
|
||||
* And returns:
|
||||
* - [ MyFile1.txt, Test/MyFile2.txt, Test/MyFile3.txt ]
|
||||
*/
|
||||
function mapFileURIsRemovingCommonRadix(fileURIs: string[]): string[] {
|
||||
|
||||
const filePaths = fileURIs
|
||||
.filter((path) => path.startsWith('file:'))
|
||||
.map((path) => path.slice(5));
|
||||
|
||||
if (filePaths.length < 2)
|
||||
return [];
|
||||
|
||||
const commonRadix = _findCommonStringsPrefix(filePaths);
|
||||
if (!commonRadix.endsWith('/'))
|
||||
return [];
|
||||
|
||||
return filePaths.map((path) => path.slice(commonRadix.length));
|
||||
}
|
||||
|
||||
function _findCommonStringsPrefix(strings: string[]) {
|
||||
if (!strings.length)
|
||||
return '';
|
||||
|
||||
const sortedStrings = strings.slice().sort();
|
||||
const firstString = sortedStrings[0];
|
||||
const lastString = sortedStrings[sortedStrings.length - 1];
|
||||
|
||||
let commonPrefix = '';
|
||||
for (let i = 0; i < firstString.length; i++) {
|
||||
if (firstString[i] === lastString[i]) {
|
||||
commonPrefix += firstString[i];
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return commonPrefix;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FileWithHandle } from 'browser-fs-access';
|
||||
|
||||
|
||||
/**
|
||||
* Extending the `FileSystemDirectoryHandle` with a `values` method to iterate over the directory contents.
|
||||
* This is as defined in https://fs.spec.whatwg.org/#filesystemdirectoryhandle (File System Standard, Last Updated 28 June 2024).
|
||||
*/
|
||||
interface ExplorableFileSystemDirectoryHandle extends FileSystemDirectoryHandle {
|
||||
values?: () => AsyncIterable<FileSystemFileHandle | FileSystemDirectoryHandle | null>;
|
||||
}
|
||||
|
||||
interface FileWithHandleAndPath {
|
||||
fileWithHandle: FileWithHandle;
|
||||
relativeName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively get all files from a directory, returning an array of `FileWithHandleAndPath` objects,
|
||||
* where the handles are all FileSystemFileHandle objects (allows for LiveFile support).
|
||||
*/
|
||||
export async function getAllFilesFromDirectoryRecursively(directoryHandle: FileSystemDirectoryHandle): Promise<FileWithHandleAndPath[]> {
|
||||
const list: FileWithHandleAndPath[] = [];
|
||||
const separator = '/';
|
||||
|
||||
async function traverseDirectory(dirHandle: ExplorableFileSystemDirectoryHandle, path: string = '') {
|
||||
if ('values' in dirHandle && typeof dirHandle.values === 'function') {
|
||||
for await (const handle of dirHandle.values()) {
|
||||
if (!handle) continue;
|
||||
const relativePath = path ? `${path}${separator}${handle.name}` : handle.name;
|
||||
|
||||
if (handle.kind === 'file') {
|
||||
const fileWithHandle = await handle.getFile() as FileWithHandle;
|
||||
fileWithHandle.handle = handle;
|
||||
list.push({
|
||||
fileWithHandle: fileWithHandle,
|
||||
relativeName: relativePath,
|
||||
});
|
||||
} else if (handle.kind === 'directory') {
|
||||
await traverseDirectory(handle, relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await traverseDirectory(directoryHandle);
|
||||
return list;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { AttachmentDraftSource } from '~/common/attachment-drafts/attachment.types';
|
||||
import type { DMessageAttachmentFragment } from '~/common/stores/chat/chat.fragments';
|
||||
|
||||
|
||||
export function liveFileIsSupported(source: AttachmentDraftSource): boolean {
|
||||
return source.media === 'file' && !!source.fileWithHandle.handle && typeof source.fileWithHandle.handle.getFile === 'function';
|
||||
}
|
||||
|
||||
export function liveFileAddToAttachmentFragment(source: AttachmentDraftSource, attachmentFragment: DMessageAttachmentFragment) {
|
||||
if (source.media === 'file' && !!source.fileWithHandle.handle && typeof source.fileWithHandle.handle.getFile === 'function')
|
||||
attachmentFragment._fileSystemFileHandle = source.fileWithHandle.handle;
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export type DMessageAttachmentFragment = _DMessageFragmentWrapper<'attachment',
|
||||
title: string; // label of the attachment (filename, named id, content overview, title..)
|
||||
caption: string; // additional information, such as provenance, content preview, etc.
|
||||
created: number;
|
||||
_fileSystemFileHandle?: FileSystemFileHandle; // [LiveFile] - live file support, while in-memory
|
||||
};
|
||||
|
||||
// Future Examples: up to 1 per message, containing the Rays and Merges that would be used to restore the Beam state - could be volatile (omitted at save)
|
||||
|
||||
Reference in New Issue
Block a user