mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-01 22:00:18 +01:00
feat: add file attachment section PromptFiles, new file display: PromptFile (needed for deletion to work properly), and usePromptFileHandling hook
This commit is contained in:
parent
600641d02f
commit
623dfa5b63
7 changed files with 758 additions and 4 deletions
|
|
@ -3,11 +3,12 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { Button, TextareaAutosize, Input } from '@librechat/client';
|
import { Button, TextareaAutosize, Input } from '@librechat/client';
|
||||||
import { useForm, Controller, FormProvider } from 'react-hook-form';
|
import { useForm, Controller, FormProvider } from 'react-hook-form';
|
||||||
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
|
import { LocalStorageKeys, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||||
|
import type { AgentToolResources } from 'librechat-data-provider';
|
||||||
|
import PromptVariablesAndFiles from '~/components/Prompts/PromptVariablesAndFiles';
|
||||||
import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
|
import CategorySelector from '~/components/Prompts/Groups/CategorySelector';
|
||||||
|
import { useLocalize, useHasAccess, usePromptFileHandling } from '~/hooks';
|
||||||
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
|
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
|
||||||
import PromptVariables from '~/components/Prompts/PromptVariables';
|
|
||||||
import Description from '~/components/Prompts/Description';
|
import Description from '~/components/Prompts/Description';
|
||||||
import { useLocalize, useHasAccess } from '~/hooks';
|
|
||||||
import Command from '~/components/Prompts/Command';
|
import Command from '~/components/Prompts/Command';
|
||||||
import { useCreatePrompt } from '~/data-provider';
|
import { useCreatePrompt } from '~/data-provider';
|
||||||
import { cn } from '~/utils';
|
import { cn } from '~/utils';
|
||||||
|
|
@ -19,6 +20,7 @@ type CreateFormValues = {
|
||||||
category: string;
|
category: string;
|
||||||
oneliner?: string;
|
oneliner?: string;
|
||||||
command?: string;
|
command?: string;
|
||||||
|
tool_resources?: AgentToolResources;
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultPrompt: CreateFormValues = {
|
const defaultPrompt: CreateFormValues = {
|
||||||
|
|
@ -37,6 +39,15 @@ const CreatePromptForm = ({
|
||||||
}) => {
|
}) => {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const {
|
||||||
|
promptFiles: files,
|
||||||
|
setFiles,
|
||||||
|
handleFileChange,
|
||||||
|
handleFileRemove,
|
||||||
|
getToolResources,
|
||||||
|
} = usePromptFileHandling();
|
||||||
|
|
||||||
const hasAccess = useHasAccess({
|
const hasAccess = useHasAccess({
|
||||||
permissionType: PermissionTypes.PROMPTS,
|
permissionType: PermissionTypes.PROMPTS,
|
||||||
permission: Permissions.CREATE,
|
permission: Permissions.CREATE,
|
||||||
|
|
@ -88,8 +99,15 @@ const CreatePromptForm = ({
|
||||||
if ((command?.length ?? 0) > 0) {
|
if ((command?.length ?? 0) > 0) {
|
||||||
groupData.command = command;
|
groupData.command = command;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promptData = { ...rest };
|
||||||
|
const toolResources = getToolResources();
|
||||||
|
if (toolResources) {
|
||||||
|
promptData.tool_resources = toolResources;
|
||||||
|
}
|
||||||
|
|
||||||
createPromptMutation.mutate({
|
createPromptMutation.mutate({
|
||||||
prompt: rest,
|
prompt: promptData,
|
||||||
group: groupData,
|
group: groupData,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -161,7 +179,14 @@ const CreatePromptForm = ({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PromptVariables promptText={promptText} />
|
<PromptVariablesAndFiles
|
||||||
|
promptText={promptText}
|
||||||
|
files={files}
|
||||||
|
onFilesChange={setFiles}
|
||||||
|
handleFileChange={handleFileChange}
|
||||||
|
onFileRemove={handleFileRemove}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
/>
|
||||||
<Description
|
<Description
|
||||||
onValueChange={(value) => methods.setValue('oneliner', value)}
|
onValueChange={(value) => methods.setValue('oneliner', value)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
|
||||||
172
client/src/components/Prompts/PromptFile.tsx
Normal file
172
client/src/components/Prompts/PromptFile.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useToastContext } from '@librechat/client';
|
||||||
|
import { FileSources } from 'librechat-data-provider';
|
||||||
|
import type { ExtendedFile } from '~/common';
|
||||||
|
import FileContainer from '~/components/Chat/Input/Files/FileContainer';
|
||||||
|
import { useDeleteFilesMutation } from '~/data-provider';
|
||||||
|
import Image from '~/components/Chat/Input/Files/Image';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
import { logger } from '~/utils';
|
||||||
|
|
||||||
|
export default function PromptFile({
|
||||||
|
files: _files,
|
||||||
|
setFiles,
|
||||||
|
abortUpload,
|
||||||
|
setFilesLoading,
|
||||||
|
onFileRemove,
|
||||||
|
fileFilter,
|
||||||
|
isRTL = false,
|
||||||
|
Wrapper,
|
||||||
|
}: {
|
||||||
|
files: Map<string, ExtendedFile> | undefined;
|
||||||
|
abortUpload?: () => void;
|
||||||
|
setFiles: React.Dispatch<React.SetStateAction<Map<string, ExtendedFile>>>;
|
||||||
|
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
onFileRemove?: (fileId: string) => void;
|
||||||
|
fileFilter?: (file: ExtendedFile) => boolean;
|
||||||
|
isRTL?: boolean;
|
||||||
|
Wrapper?: React.FC<{ children: React.ReactNode }>;
|
||||||
|
}) {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const files = Array.from(_files?.values() ?? []).filter((file) =>
|
||||||
|
fileFilter ? fileFilter(file) : true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { mutateAsync } = useDeleteFilesMutation({
|
||||||
|
onMutate: async () =>
|
||||||
|
logger.log(
|
||||||
|
'prompts',
|
||||||
|
'Deleting prompt files',
|
||||||
|
files.map((f) => f.file_id),
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
console.log('Prompt files deleted');
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.log('Error deleting prompt files:', error);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (files.length === 0) {
|
||||||
|
setFilesLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.some((file) => file.progress < 1)) {
|
||||||
|
setFilesLoading(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (files.every((file) => file.progress === 1)) {
|
||||||
|
setFilesLoading(false);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderFiles = () => {
|
||||||
|
const rowStyle = isRTL
|
||||||
|
? {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'row-reverse',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '4px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
display: 'flex',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: '4px',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: '100%',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={rowStyle as React.CSSProperties}>
|
||||||
|
{files
|
||||||
|
.reduce(
|
||||||
|
(acc, current) => {
|
||||||
|
if (!acc.map.has(current.file_id)) {
|
||||||
|
acc.map.set(current.file_id, true);
|
||||||
|
acc.uniqueFiles.push(current);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ map: new Map(), uniqueFiles: [] as ExtendedFile[] },
|
||||||
|
)
|
||||||
|
.uniqueFiles.map((file: ExtendedFile, index: number) => {
|
||||||
|
const handleDelete = () => {
|
||||||
|
showToast({
|
||||||
|
message: localize('com_ui_deleting_file'),
|
||||||
|
status: 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (abortUpload && file.progress < 1) {
|
||||||
|
abortUpload();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onFileRemove) {
|
||||||
|
onFileRemove(file.file_id);
|
||||||
|
} else {
|
||||||
|
mutateAsync({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file_id: file.file_id,
|
||||||
|
filepath: file.filepath || '',
|
||||||
|
embedded: file.embedded || false,
|
||||||
|
source: file.source || FileSources.local,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
setFiles((currentFiles) => {
|
||||||
|
const updatedFiles = new Map(currentFiles);
|
||||||
|
updatedFiles.delete(file.file_id);
|
||||||
|
if (file.temp_file_id) {
|
||||||
|
updatedFiles.delete(file.temp_file_id);
|
||||||
|
}
|
||||||
|
return updatedFiles;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImage = file.type?.startsWith('image') ?? false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
flexBasis: '70px',
|
||||||
|
flexGrow: 0,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isImage ? (
|
||||||
|
<Image
|
||||||
|
url={file.preview ?? file.filepath}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
progress={file.progress}
|
||||||
|
source={file.source}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<FileContainer file={file} onDelete={handleDelete} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Wrapper) {
|
||||||
|
return <Wrapper>{renderFiles()}</Wrapper>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderFiles();
|
||||||
|
}
|
||||||
81
client/src/components/Prompts/PromptFiles.tsx
Normal file
81
client/src/components/Prompts/PromptFiles.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { FileText } from 'lucide-react';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { Separator } from '@librechat/client';
|
||||||
|
import type { ExtendedFile } from '~/common';
|
||||||
|
import AttachFileButton from '~/components/Prompts/Files/AttachFileButton';
|
||||||
|
import PromptFile from '~/components/Prompts/PromptFile';
|
||||||
|
import { useLocalize } from '~/hooks';
|
||||||
|
|
||||||
|
const PromptFiles = ({
|
||||||
|
files,
|
||||||
|
onFilesChange,
|
||||||
|
handleFileChange,
|
||||||
|
onFileRemove,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
files: ExtendedFile[];
|
||||||
|
onFilesChange?: (files: ExtendedFile[]) => void;
|
||||||
|
handleFileChange?: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
|
||||||
|
onFileRemove?: (fileId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}) => {
|
||||||
|
const localize = useLocalize();
|
||||||
|
|
||||||
|
const filesMap = useMemo(() => {
|
||||||
|
const map = new Map<string, ExtendedFile>();
|
||||||
|
files.forEach((file) => {
|
||||||
|
const key = file.file_id || file.temp_file_id || '';
|
||||||
|
if (key) {
|
||||||
|
map.set(key, file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [files]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col rounded-xl border border-border-light bg-transparent p-4 shadow-md">
|
||||||
|
<h3 className="flex items-center gap-2 py-2 text-lg font-semibold text-text-primary">
|
||||||
|
<FileText className="icon-sm" aria-hidden="true" />
|
||||||
|
{localize('com_ui_files')}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-1 flex-col space-y-4">
|
||||||
|
{!files.length && (
|
||||||
|
<div className="text-sm text-text-secondary">
|
||||||
|
<ReactMarkdown className="markdown prose dark:prose-invert">
|
||||||
|
{localize('com_ui_files_info')}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="mb-3 flex-1">
|
||||||
|
<PromptFile
|
||||||
|
files={filesMap}
|
||||||
|
setFiles={(newMapOrUpdater) => {
|
||||||
|
const newMap =
|
||||||
|
typeof newMapOrUpdater === 'function'
|
||||||
|
? newMapOrUpdater(filesMap)
|
||||||
|
: newMapOrUpdater;
|
||||||
|
const newFiles = Array.from(newMap.values()) as ExtendedFile[];
|
||||||
|
onFilesChange?.(newFiles);
|
||||||
|
}}
|
||||||
|
setFilesLoading={() => {}}
|
||||||
|
onFileRemove={onFileRemove}
|
||||||
|
Wrapper={({ children }) => <div className="flex flex-wrap gap-2">{children}</div>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Separator className="my-3 text-text-primary" />
|
||||||
|
<div className="flex flex-col justify-end text-text-secondary">
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<AttachFileButton handleFileChange={handleFileChange} disabled={disabled} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromptFiles;
|
||||||
46
client/src/components/Prompts/PromptVariablesAndFiles.tsx
Normal file
46
client/src/components/Prompts/PromptVariablesAndFiles.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import React from 'react';
|
||||||
|
import type { ExtendedFile } from '~/common';
|
||||||
|
import PromptVariables from './PromptVariables';
|
||||||
|
import PromptFiles from './PromptFiles';
|
||||||
|
|
||||||
|
interface PromptVariablesAndFilesProps {
|
||||||
|
promptText: string;
|
||||||
|
files?: ExtendedFile[];
|
||||||
|
onFilesChange?: (files: ExtendedFile[]) => void;
|
||||||
|
handleFileChange?: (event: React.ChangeEvent<HTMLInputElement>, toolResource?: string) => void;
|
||||||
|
onFileRemove?: (fileId: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
showVariablesInfo?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PromptVariablesAndFiles: React.FC<PromptVariablesAndFilesProps> = ({
|
||||||
|
promptText,
|
||||||
|
files = [],
|
||||||
|
onFilesChange,
|
||||||
|
handleFileChange,
|
||||||
|
onFileRemove,
|
||||||
|
disabled,
|
||||||
|
showVariablesInfo = true,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:items-stretch">
|
||||||
|
{/* Variables Section */}
|
||||||
|
<div className="w-full">
|
||||||
|
<PromptVariables promptText={promptText} showInfo={showVariablesInfo} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Files Section */}
|
||||||
|
<div className="w-full">
|
||||||
|
<PromptFiles
|
||||||
|
files={files}
|
||||||
|
onFilesChange={onFilesChange}
|
||||||
|
handleFileChange={handleFileChange}
|
||||||
|
onFileRemove={onFileRemove}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PromptVariablesAndFiles;
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
export { default as useCategories } from './useCategories';
|
export { default as useCategories } from './useCategories';
|
||||||
export { default as usePromptGroupsNav } from './usePromptGroupsNav';
|
export { default as usePromptGroupsNav } from './usePromptGroupsNav';
|
||||||
|
export { default as usePromptFileHandling } from './usePromptFileHandling';
|
||||||
|
|
|
||||||
424
client/src/hooks/Prompts/usePromptFileHandling.ts
Normal file
424
client/src/hooks/Prompts/usePromptFileHandling.ts
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
import React, { useState, useCallback, useMemo, useRef } from 'react';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
import { useToastContext } from '@librechat/client';
|
||||||
|
import { EModelEndpoint, EToolResources, FileSources } from 'librechat-data-provider';
|
||||||
|
import type { AgentToolResources, TFile } from 'librechat-data-provider';
|
||||||
|
import type { ExtendedFile } from '~/common';
|
||||||
|
import { useUploadFileMutation, useGetFiles, useDeleteFilesMutation } from '~/data-provider';
|
||||||
|
import { useAuthContext } from '~/hooks';
|
||||||
|
import { logger } from '~/utils';
|
||||||
|
|
||||||
|
interface UsePromptFileHandling {
|
||||||
|
fileSetter?: (files: ExtendedFile[]) => void;
|
||||||
|
initialFiles?: ExtendedFile[];
|
||||||
|
onFileChange?: () => void; // Callback when files are added/removed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simplified file handling hook for prompts that doesn't depend on ChatContext
|
||||||
|
*/
|
||||||
|
export const usePromptFileHandling = (params?: UsePromptFileHandling) => {
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const { user } = useAuthContext();
|
||||||
|
const { data: allFiles = [] } = useGetFiles();
|
||||||
|
|
||||||
|
// Create a fileMap for quick lookup
|
||||||
|
const fileMap = useMemo(() => {
|
||||||
|
const map: Record<string, TFile> = {};
|
||||||
|
allFiles.forEach((file) => {
|
||||||
|
if (file.file_id) {
|
||||||
|
map[file.file_id] = file;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [allFiles]);
|
||||||
|
const [files, setFiles] = useState<ExtendedFile[]>(() => {
|
||||||
|
return params?.initialFiles || [];
|
||||||
|
});
|
||||||
|
const [filesLoading, setFilesLoading] = useState(false);
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
const deleteFileMutation = useDeleteFilesMutation();
|
||||||
|
|
||||||
|
const uploadFile = useUploadFileMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
logger.log('File uploaded successfully', data);
|
||||||
|
|
||||||
|
setFiles((prev) => {
|
||||||
|
return prev.map((file) => {
|
||||||
|
if (file.temp_file_id === data.temp_file_id) {
|
||||||
|
return {
|
||||||
|
...file,
|
||||||
|
file_id: data.file_id,
|
||||||
|
filepath: data.filepath,
|
||||||
|
progress: 1,
|
||||||
|
attached: true,
|
||||||
|
preview: file.preview,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return file;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
setFilesLoading(false);
|
||||||
|
showToast({
|
||||||
|
message: 'File uploaded successfully',
|
||||||
|
status: 'success',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error, body) => {
|
||||||
|
logger.error('File upload error:', error);
|
||||||
|
setFilesLoading(false);
|
||||||
|
|
||||||
|
// Remove the failed file from the UI
|
||||||
|
const file_id = body.get('file_id');
|
||||||
|
if (file_id) {
|
||||||
|
setFiles((prev) => {
|
||||||
|
return prev.filter((file) => {
|
||||||
|
if (file.file_id === file_id || file.temp_file_id === file_id) {
|
||||||
|
// Clean up blob URL if it exists
|
||||||
|
if (file.preview && file.preview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.preview);
|
||||||
|
}
|
||||||
|
return false; // Remove this file
|
||||||
|
}
|
||||||
|
return true; // Keep this file
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show specific error message
|
||||||
|
let errorMessage = 'Failed to upload file';
|
||||||
|
if (error?.response?.data?.message) {
|
||||||
|
errorMessage = error.response.data.message;
|
||||||
|
} else if (error?.message) {
|
||||||
|
errorMessage = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast({
|
||||||
|
message: errorMessage,
|
||||||
|
status: 'error',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Files are already an array, no conversion needed
|
||||||
|
const promptFiles = files;
|
||||||
|
|
||||||
|
// Call fileSetter when files change
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (params?.fileSetter) {
|
||||||
|
params.fileSetter(files);
|
||||||
|
}
|
||||||
|
}, [files, params?.fileSetter]);
|
||||||
|
|
||||||
|
// Load image and extract dimensions (like useFileHandling does)
|
||||||
|
const loadImage = useCallback(
|
||||||
|
(extendedFile: ExtendedFile, preview: string) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = async () => {
|
||||||
|
const updatedFile = {
|
||||||
|
...extendedFile,
|
||||||
|
width: img.width,
|
||||||
|
height: img.height,
|
||||||
|
progress: 0.6,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFiles((prev) =>
|
||||||
|
prev.map((file) => (file.file_id === extendedFile.file_id ? updatedFile : file)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create form data for upload
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('endpoint', EModelEndpoint.agents);
|
||||||
|
formData.append('file', extendedFile.file!, encodeURIComponent(extendedFile.filename));
|
||||||
|
formData.append('file_id', extendedFile.file_id);
|
||||||
|
formData.append('message_file', 'true'); // For prompts, treat as message attachment
|
||||||
|
|
||||||
|
// Include dimensions for image recognition
|
||||||
|
formData.append('width', img.width.toString());
|
||||||
|
formData.append('height', img.height.toString());
|
||||||
|
|
||||||
|
if (extendedFile.tool_resource) {
|
||||||
|
formData.append('tool_resource', extendedFile.tool_resource.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload the file with dimensions
|
||||||
|
uploadFile.mutate(formData);
|
||||||
|
};
|
||||||
|
img.src = preview;
|
||||||
|
},
|
||||||
|
[uploadFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle file uploads
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>, toolResource?: EToolResources | string) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!event.target.files) return;
|
||||||
|
|
||||||
|
const fileList = Array.from(event.target.files);
|
||||||
|
setFilesLoading(true);
|
||||||
|
|
||||||
|
fileList.forEach(async (file) => {
|
||||||
|
const file_id = v4();
|
||||||
|
const temp_file_id = file_id; // Use same ID initially, backend will reassign
|
||||||
|
|
||||||
|
// Add file to state immediately with progress indicator
|
||||||
|
const extendedFile: ExtendedFile = {
|
||||||
|
file_id,
|
||||||
|
temp_file_id,
|
||||||
|
type: file.type,
|
||||||
|
filename: file.name,
|
||||||
|
filepath: '',
|
||||||
|
progress: 0,
|
||||||
|
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : '',
|
||||||
|
size: file.size,
|
||||||
|
width: undefined,
|
||||||
|
height: undefined,
|
||||||
|
attached: false,
|
||||||
|
file,
|
||||||
|
tool_resource: typeof toolResource === 'string' ? toolResource : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
setFiles((prev) => [...prev, extendedFile]);
|
||||||
|
|
||||||
|
// For images, load and extract dimensions before upload
|
||||||
|
if (file.type.startsWith('image/') && extendedFile.preview) {
|
||||||
|
loadImage(extendedFile, extendedFile.preview);
|
||||||
|
} else {
|
||||||
|
// For non-images, upload immediately
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('endpoint', EModelEndpoint.agents);
|
||||||
|
formData.append('file', file, encodeURIComponent(file.name));
|
||||||
|
formData.append('file_id', file_id);
|
||||||
|
formData.append('message_file', 'true'); // For prompts, treat as message attachment
|
||||||
|
|
||||||
|
if (toolResource) {
|
||||||
|
formData.append('tool_resource', toolResource.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadFile.mutate(formData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset input
|
||||||
|
event.target.value = '';
|
||||||
|
},
|
||||||
|
[uploadFile, loadImage],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle file removal
|
||||||
|
const handleFileRemove = useCallback(
|
||||||
|
(fileId: string) => {
|
||||||
|
// Call delete API to remove from database
|
||||||
|
deleteFileMutation.mutate([fileId]);
|
||||||
|
|
||||||
|
setFiles((prev) => {
|
||||||
|
return prev.filter((file) => {
|
||||||
|
if (file.file_id === fileId || file.temp_file_id === fileId) {
|
||||||
|
// Clean up blob URL if it exists
|
||||||
|
if (file.preview && file.preview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.preview);
|
||||||
|
}
|
||||||
|
return false; // Remove this file
|
||||||
|
}
|
||||||
|
return true; // Keep this file
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call the onFileChange callback to trigger save
|
||||||
|
params?.onFileChange?.();
|
||||||
|
},
|
||||||
|
[deleteFileMutation, params?.onFileChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync with external fileSetter when files change
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (params?.fileSetter) {
|
||||||
|
params.fileSetter(promptFiles);
|
||||||
|
}
|
||||||
|
}, [promptFiles, params?.fileSetter]);
|
||||||
|
|
||||||
|
// Cleanup blob URLs on unmount
|
||||||
|
React.useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clean up all blob URLs when component unmounts
|
||||||
|
files.forEach((file) => {
|
||||||
|
if (file.preview && file.preview.startsWith('blob:')) {
|
||||||
|
URL.revokeObjectURL(file.preview);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert current files to tool_resources format for API submission
|
||||||
|
*/
|
||||||
|
const getToolResources = useCallback((): AgentToolResources | undefined => {
|
||||||
|
if (promptFiles.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResources: AgentToolResources = {};
|
||||||
|
|
||||||
|
promptFiles.forEach((file) => {
|
||||||
|
if (!file.file_id) return; // Skip files that haven't been uploaded yet
|
||||||
|
|
||||||
|
// Determine tool resource type based on file type or explicit tool_resource
|
||||||
|
let toolResource: EToolResources;
|
||||||
|
|
||||||
|
if (file.tool_resource) {
|
||||||
|
toolResource = file.tool_resource as EToolResources;
|
||||||
|
} else if (file.type?.startsWith('image/')) {
|
||||||
|
toolResource = EToolResources.image_edit;
|
||||||
|
} else if (file.type === 'application/pdf' || file.type?.includes('text')) {
|
||||||
|
toolResource = EToolResources.file_search;
|
||||||
|
} else {
|
||||||
|
toolResource = EToolResources.file_search; // Default fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the tool resource if it doesn't exist
|
||||||
|
if (!toolResources[toolResource]) {
|
||||||
|
toolResources[toolResource] = { file_ids: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add file_id to the appropriate tool resource
|
||||||
|
if (!toolResources[toolResource]!.file_ids!.includes(file.file_id)) {
|
||||||
|
toolResources[toolResource]!.file_ids!.push(file.file_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.keys(toolResources).length > 0 ? toolResources : undefined;
|
||||||
|
}, [promptFiles]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load files from tool_resources format (for editing existing prompts)
|
||||||
|
*/
|
||||||
|
const loadFromToolResources = useCallback(
|
||||||
|
async (toolResources?: AgentToolResources) => {
|
||||||
|
if (!toolResources) {
|
||||||
|
setFiles([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filesArray: ExtendedFile[] = [];
|
||||||
|
|
||||||
|
// Process all files and create blob URLs for images
|
||||||
|
for (const [toolResource, resource] of Object.entries(toolResources)) {
|
||||||
|
if (resource?.file_ids) {
|
||||||
|
for (const fileId of resource.file_ids) {
|
||||||
|
const dbFile = fileMap[fileId];
|
||||||
|
const source =
|
||||||
|
toolResource === EToolResources.file_search
|
||||||
|
? FileSources.vectordb
|
||||||
|
: (dbFile?.source ?? FileSources.local);
|
||||||
|
|
||||||
|
let file: ExtendedFile;
|
||||||
|
|
||||||
|
if (dbFile) {
|
||||||
|
// Use real file metadata from database
|
||||||
|
file = {
|
||||||
|
file_id: dbFile.file_id,
|
||||||
|
temp_file_id: dbFile.file_id,
|
||||||
|
type: dbFile.type,
|
||||||
|
filename: dbFile.filename,
|
||||||
|
filepath: dbFile.filepath,
|
||||||
|
progress: 1,
|
||||||
|
preview: undefined, // Will be set by FilePreviewLoader
|
||||||
|
size: dbFile.bytes || 0,
|
||||||
|
width: dbFile.width,
|
||||||
|
height: dbFile.height,
|
||||||
|
attached: true,
|
||||||
|
tool_resource: toolResource,
|
||||||
|
metadata: dbFile.metadata,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Fallback to placeholder if file not found in database
|
||||||
|
file = {
|
||||||
|
file_id: fileId,
|
||||||
|
temp_file_id: fileId,
|
||||||
|
type: 'application/octet-stream',
|
||||||
|
filename: `File ${fileId}`,
|
||||||
|
filepath: '',
|
||||||
|
progress: 1,
|
||||||
|
preview: '',
|
||||||
|
size: 0,
|
||||||
|
width: undefined,
|
||||||
|
height: undefined,
|
||||||
|
attached: true,
|
||||||
|
tool_resource: toolResource,
|
||||||
|
source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
filesArray.push(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles(filesArray);
|
||||||
|
},
|
||||||
|
[fileMap, user?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if all files have been uploaded successfully
|
||||||
|
*/
|
||||||
|
const areFilesReady = useMemo(() => {
|
||||||
|
return promptFiles.every((file) => file.file_id && file.progress === 1);
|
||||||
|
}, [promptFiles]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of files by type
|
||||||
|
*/
|
||||||
|
const fileStats = useMemo(() => {
|
||||||
|
const stats = {
|
||||||
|
total: promptFiles.length,
|
||||||
|
images: 0,
|
||||||
|
documents: 0,
|
||||||
|
uploading: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
promptFiles.forEach((file) => {
|
||||||
|
if (file.progress < 1) {
|
||||||
|
stats.uploading++;
|
||||||
|
} else if (file.type?.startsWith('image/')) {
|
||||||
|
stats.images++;
|
||||||
|
} else {
|
||||||
|
stats.documents++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}, [promptFiles]);
|
||||||
|
|
||||||
|
const abortUpload = useCallback(() => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
logger.log('files', 'Aborting upload');
|
||||||
|
abortControllerRef.current.abort('User aborted upload');
|
||||||
|
abortControllerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
// File handling functions
|
||||||
|
handleFileChange,
|
||||||
|
abortUpload,
|
||||||
|
|
||||||
|
// File state
|
||||||
|
files,
|
||||||
|
setFiles,
|
||||||
|
promptFiles,
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
getToolResources,
|
||||||
|
loadFromToolResources,
|
||||||
|
areFilesReady,
|
||||||
|
fileStats,
|
||||||
|
handleFileRemove,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default usePromptFileHandling;
|
||||||
|
|
@ -703,6 +703,7 @@
|
||||||
"com_ui_attach_error_openai": "Cannot attach Assistant files to other endpoints",
|
"com_ui_attach_error_openai": "Cannot attach Assistant files to other endpoints",
|
||||||
"com_ui_attach_error_size": "File size limit exceeded for endpoint:",
|
"com_ui_attach_error_size": "File size limit exceeded for endpoint:",
|
||||||
"com_ui_attach_error_type": "Unsupported file type for endpoint:",
|
"com_ui_attach_error_type": "Unsupported file type for endpoint:",
|
||||||
|
"com_ui_attach_files": "Attach Files",
|
||||||
"com_ui_attach_remove": "Remove file",
|
"com_ui_attach_remove": "Remove file",
|
||||||
"com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool",
|
"com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool",
|
||||||
"com_ui_attachment": "Attachment",
|
"com_ui_attachment": "Attachment",
|
||||||
|
|
@ -897,6 +898,8 @@
|
||||||
"com_ui_file_token_limit": "File Token Limit",
|
"com_ui_file_token_limit": "File Token Limit",
|
||||||
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
"com_ui_file_token_limit_desc": "Set maximum token limit for file processing to control costs and resource usage",
|
||||||
"com_ui_files": "Files",
|
"com_ui_files": "Files",
|
||||||
|
"com_ui_files_attached": "files attached",
|
||||||
|
"com_ui_files_info": "Attach files to enhance your prompt with additional context",
|
||||||
"com_ui_filter_prompts": "Filter Prompts",
|
"com_ui_filter_prompts": "Filter Prompts",
|
||||||
"com_ui_filter_prompts_name": "Filter prompts by name",
|
"com_ui_filter_prompts_name": "Filter prompts by name",
|
||||||
"com_ui_final_touch": "Final touch",
|
"com_ui_final_touch": "Final touch",
|
||||||
|
|
@ -1232,6 +1235,8 @@
|
||||||
"com_ui_upload_ocr_text": "Upload as Text",
|
"com_ui_upload_ocr_text": "Upload as Text",
|
||||||
"com_ui_upload_success": "Successfully uploaded file",
|
"com_ui_upload_success": "Successfully uploaded file",
|
||||||
"com_ui_upload_type": "Select Upload Type",
|
"com_ui_upload_type": "Select Upload Type",
|
||||||
|
"com_ui_uploading": "Uploading",
|
||||||
|
"com_ui_remove_file": "Remove file",
|
||||||
"com_ui_usage": "Usage",
|
"com_ui_usage": "Usage",
|
||||||
"com_ui_use_2fa_code": "Use 2FA Code Instead",
|
"com_ui_use_2fa_code": "Use 2FA Code Instead",
|
||||||
"com_ui_use_backup_code": "Use Backup Code Instead",
|
"com_ui_use_backup_code": "Use Backup Code Instead",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue