diff --git a/client/src/components/Prompts/Groups/CreatePromptForm.tsx b/client/src/components/Prompts/Groups/CreatePromptForm.tsx index 0cb04c3779..acddce7bcf 100644 --- a/client/src/components/Prompts/Groups/CreatePromptForm.tsx +++ b/client/src/components/Prompts/Groups/CreatePromptForm.tsx @@ -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 = ({ /> - + methods.setValue('oneliner', value)} tabIndex={0} diff --git a/client/src/components/Prompts/PromptFile.tsx b/client/src/components/Prompts/PromptFile.tsx new file mode 100644 index 0000000000..0c84d45ae8 --- /dev/null +++ b/client/src/components/Prompts/PromptFile.tsx @@ -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 | undefined; + abortUpload?: () => void; + setFiles: React.Dispatch>>; + setFilesLoading: React.Dispatch>; + 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 ( +
+ {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 ( +
+ {isImage ? ( + + ) : ( + + )} +
+ ); + })} +
+ ); + }; + + if (Wrapper) { + return {renderFiles()}; + } + + return renderFiles(); +} diff --git a/client/src/components/Prompts/PromptFiles.tsx b/client/src/components/Prompts/PromptFiles.tsx new file mode 100644 index 0000000000..24663a9650 --- /dev/null +++ b/client/src/components/Prompts/PromptFiles.tsx @@ -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, toolResource?: string) => void; + onFileRemove?: (fileId: string) => void; + disabled?: boolean; +}) => { + const localize = useLocalize(); + + const filesMap = useMemo(() => { + const map = new Map(); + files.forEach((file) => { + const key = file.file_id || file.temp_file_id || ''; + if (key) { + map.set(key, file); + } + }); + return map; + }, [files]); + + return ( +
+

+

+
+ {!files.length && ( +
+ + {localize('com_ui_files_info')} + +
+ )} + + {files.length > 0 && ( +
+ { + const newMap = + typeof newMapOrUpdater === 'function' + ? newMapOrUpdater(filesMap) + : newMapOrUpdater; + const newFiles = Array.from(newMap.values()) as ExtendedFile[]; + onFilesChange?.(newFiles); + }} + setFilesLoading={() => {}} + onFileRemove={onFileRemove} + Wrapper={({ children }) =>
{children}
} + /> +
+ )} + + +
+
+ +
+
+
+
+ ); +}; + +export default PromptFiles; diff --git a/client/src/components/Prompts/PromptVariablesAndFiles.tsx b/client/src/components/Prompts/PromptVariablesAndFiles.tsx new file mode 100644 index 0000000000..cae4360203 --- /dev/null +++ b/client/src/components/Prompts/PromptVariablesAndFiles.tsx @@ -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, toolResource?: string) => void; + onFileRemove?: (fileId: string) => void; + disabled?: boolean; + showVariablesInfo?: boolean; +} + +const PromptVariablesAndFiles: React.FC = ({ + promptText, + files = [], + onFilesChange, + handleFileChange, + onFileRemove, + disabled, + showVariablesInfo = true, +}) => { + return ( +
+ {/* Variables Section */} +
+ +
+ + {/* Files Section */} +
+ +
+
+ ); +}; + +export default PromptVariablesAndFiles; diff --git a/client/src/hooks/Prompts/index.ts b/client/src/hooks/Prompts/index.ts index bc170f13ff..5e9c958d21 100644 --- a/client/src/hooks/Prompts/index.ts +++ b/client/src/hooks/Prompts/index.ts @@ -1,2 +1,3 @@ export { default as useCategories } from './useCategories'; export { default as usePromptGroupsNav } from './usePromptGroupsNav'; +export { default as usePromptFileHandling } from './usePromptFileHandling'; diff --git a/client/src/hooks/Prompts/usePromptFileHandling.ts b/client/src/hooks/Prompts/usePromptFileHandling.ts new file mode 100644 index 0000000000..3d03c10661 --- /dev/null +++ b/client/src/hooks/Prompts/usePromptFileHandling.ts @@ -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 = {}; + allFiles.forEach((file) => { + if (file.file_id) { + map[file.file_id] = file; + } + }); + return map; + }, [allFiles]); + const [files, setFiles] = useState(() => { + return params?.initialFiles || []; + }); + const [filesLoading, setFilesLoading] = useState(false); + const abortControllerRef = useRef(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, 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; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 60e226364f..8fc3da461e 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -703,6 +703,7 @@ "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_type": "Unsupported file type for endpoint:", + "com_ui_attach_files": "Attach Files", "com_ui_attach_remove": "Remove file", "com_ui_attach_warn_endpoint": "Non-Assistant files may be ignored without a compatible tool", "com_ui_attachment": "Attachment", @@ -897,6 +898,8 @@ "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_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_name": "Filter prompts by name", "com_ui_final_touch": "Final touch", @@ -1232,6 +1235,8 @@ "com_ui_upload_ocr_text": "Upload as Text", "com_ui_upload_success": "Successfully uploaded file", "com_ui_upload_type": "Select Upload Type", + "com_ui_uploading": "Uploading", + "com_ui_remove_file": "Remove file", "com_ui_usage": "Usage", "com_ui_use_2fa_code": "Use 2FA Code Instead", "com_ui_use_backup_code": "Use Backup Code Instead",