feat: add file attachment section PromptFiles, new file display: PromptFile (needed for deletion to work properly), and usePromptFileHandling hook

This commit is contained in:
Dustin Healy 2025-09-05 21:15:29 -07:00
parent 600641d02f
commit 623dfa5b63
7 changed files with 758 additions and 4 deletions

View file

@ -3,11 +3,12 @@ import { useNavigate } from 'react-router-dom';
import { Button, TextareaAutosize, Input } from '@librechat/client';
import { useForm, Controller, FormProvider } from 'react-hook-form';
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 { useLocalize, useHasAccess, usePromptFileHandling } from '~/hooks';
import VariablesDropdown from '~/components/Prompts/VariablesDropdown';
import PromptVariables from '~/components/Prompts/PromptVariables';
import Description from '~/components/Prompts/Description';
import { useLocalize, useHasAccess } from '~/hooks';
import Command from '~/components/Prompts/Command';
import { useCreatePrompt } from '~/data-provider';
import { cn } from '~/utils';
@ -19,6 +20,7 @@ type CreateFormValues = {
category: string;
oneliner?: string;
command?: string;
tool_resources?: AgentToolResources;
};
const defaultPrompt: CreateFormValues = {
@ -37,6 +39,15 @@ const CreatePromptForm = ({
}) => {
const localize = useLocalize();
const navigate = useNavigate();
const {
promptFiles: files,
setFiles,
handleFileChange,
handleFileRemove,
getToolResources,
} = usePromptFileHandling();
const hasAccess = useHasAccess({
permissionType: PermissionTypes.PROMPTS,
permission: Permissions.CREATE,
@ -88,8 +99,15 @@ const CreatePromptForm = ({
if ((command?.length ?? 0) > 0) {
groupData.command = command;
}
const promptData = { ...rest };
const toolResources = getToolResources();
if (toolResources) {
promptData.tool_resources = toolResources;
}
createPromptMutation.mutate({
prompt: rest,
prompt: promptData,
group: groupData,
});
};
@ -161,7 +179,14 @@ const CreatePromptForm = ({
/>
</div>
</div>
<PromptVariables promptText={promptText} />
<PromptVariablesAndFiles
promptText={promptText}
files={files}
onFilesChange={setFiles}
handleFileChange={handleFileChange}
onFileRemove={handleFileRemove}
disabled={isSubmitting}
/>
<Description
onValueChange={(value) => methods.setValue('oneliner', value)}
tabIndex={0}

View 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();
}

View 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;

View 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;