diff --git a/client/src/Providers/UploadModalContext.tsx b/client/src/Providers/UploadModalContext.tsx new file mode 100644 index 0000000000..d202f26405 --- /dev/null +++ b/client/src/Providers/UploadModalContext.tsx @@ -0,0 +1,43 @@ +import React, { createContext, useCallback, useContext, useMemo, useState } from 'react'; + +interface UploadModalContextValue { + isVisible: boolean; + files: File[]; + openModal: (files: File[]) => void; + closeModal: () => void; +} + +const defaultValue: UploadModalContextValue = { + isVisible: false, + files: [], + openModal: () => undefined, + closeModal: () => undefined, +}; + +const UploadModalContext = createContext(defaultValue); + +export function UploadModalProvider({ children }: { children: React.ReactNode }) { + const [isVisible, setIsVisible] = useState(false); + const [files, setFiles] = useState([]); + + const openModal = useCallback((nextFiles: File[]) => { + setFiles(nextFiles); + setIsVisible(true); + }, []); + + const closeModal = useCallback(() => { + setIsVisible(false); + setFiles([]); + }, []); + + const value = useMemo( + () => ({ isVisible, files, openModal, closeModal }), + [isVisible, files, openModal, closeModal], + ); + + return {children}; +} + +export function useUploadModalContext() { + return useContext(UploadModalContext); +} diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index aac5fccc21..2752e09178 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -22,6 +22,7 @@ export * from './SetConvoContext'; export * from './SearchContext'; export * from './BadgeRowContext'; export * from './DragDropContext'; +export * from './UploadModalContext'; export * from './ArtifactsContext'; export * from './PromptGroupsContext'; export * from './MessagesViewContext'; diff --git a/client/src/components/Chat/Input/Files/DragDropModal.tsx b/client/src/components/Chat/Input/Files/DragDropModal.tsx index 103f7326e0..618ab20a15 100644 --- a/client/src/components/Chat/Input/Files/DragDropModal.tsx +++ b/client/src/components/Chat/Input/Files/DragDropModal.tsx @@ -2,179 +2,114 @@ import React, { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; import { OGDialog, OGDialogTemplate } from '@librechat/client'; import { - ImageUpIcon, FileSearch, + ImageUpIcon, FileType2Icon, FileImageIcon, TerminalSquareIcon, } from 'lucide-react'; import { + Constants, Providers, - inferMimeType, EToolResources, EModelEndpoint, - isBedrockDocumentType, - defaultAgentCapabilities, isDocumentSupportedProvider, } from 'librechat-data-provider'; import { - useAgentToolPermissions, - useAgentCapabilities, - useGetAgentsConfig, useLocalize, + useUploadOptions, + useFileUploadRouter, + useAgentToolPermissions, } from '~/hooks'; +import { useDragDropContext, useUploadModalContext } from '~/Providers'; import { ephemeralAgentByConvoId } from '~/store'; -import { useDragDropContext } from '~/Providers'; -interface DragDropModalProps { - onOptionSelect: (option: EToolResources | undefined) => void; - files: File[]; - isVisible: boolean; - setShowModal: (showModal: boolean) => void; -} - -interface FileOption { - label: string; - value?: EToolResources; - icon: React.JSX.Element; - condition?: boolean; -} - -const DragDropModal = ({ onOptionSelect, setShowModal, files, isVisible }: DragDropModalProps) => { +const DragDropModal = () => { const localize = useLocalize(); - const { agentsConfig } = useGetAgentsConfig(); - /** TODO: Ephemeral Agent Capabilities - * Allow defining agent capabilities on a per-endpoint basis - * Use definition for agents endpoint for ephemeral agents - * */ - const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); + const { isVisible, files, closeModal } = useUploadModalContext(); const { conversationId, agentId, endpoint, endpointType, useResponsesApi } = useDragDropContext(); - const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(conversationId ?? '')); - const { fileSearchAllowedByAgent, codeAllowedByAgent, provider } = useAgentToolPermissions( - agentId, - ephemeralAgent, + const ephemeralAgent = useRecoilValue( + ephemeralAgentByConvoId(conversationId ?? Constants.NEW_CONVO), ); + const { provider } = useAgentToolPermissions(agentId, ephemeralAgent); + const { getOptions } = useUploadOptions(); + const routeFiles = useFileUploadRouter(); - const options = useMemo(() => { - const _options: FileOption[] = []; - let currentProvider = provider || endpoint; - - // This will be removed in a future PR to formally normalize Providers comparisons to be case insensitive - if (currentProvider?.toLowerCase() === Providers.OPENROUTER) { + const isProviderDocSupported = useMemo(() => { + let currentProvider = (provider || endpoint) ?? ''; + if (currentProvider.toLowerCase() === Providers.OPENROUTER) { currentProvider = Providers.OPENROUTER; } - - /** Helper to get inferred MIME type for a file */ - const getFileType = (file: File) => inferMimeType(file.name, file.type); - const isAzureWithResponsesApi = (currentProvider === EModelEndpoint.azureOpenAI || endpointType === EModelEndpoint.azureOpenAI) && useResponsesApi === true; - - // Check if provider supports document upload - if ( + return ( isDocumentSupportedProvider(endpointType) || isDocumentSupportedProvider(currentProvider) || isAzureWithResponsesApi - ) { - const supportsImageDocVideoAudio = - currentProvider === EModelEndpoint.google || currentProvider === Providers.OPENROUTER; - const isBedrock = - currentProvider === Providers.BEDROCK || endpointType === EModelEndpoint.bedrock; + ); + }, [provider, endpoint, endpointType, useResponsesApi]); - const isValidProviderFile = (file: File): boolean => { - const type = getFileType(file); - if (supportsImageDocVideoAudio) { - return ( - type?.startsWith('image/') || - type?.startsWith('video/') || - type?.startsWith('audio/') || - type === 'application/pdf' - ); - } - if (isBedrock) { - return type?.startsWith('image/') || isBedrockDocumentType(type); - } - return type?.startsWith('image/') || type === 'application/pdf'; - }; + const getOptionMeta = (value: EToolResources | undefined) => { + switch (value) { + case EToolResources.file_search: + return { + label: localize('com_ui_upload_file_search'), + icon: , + }; + case EToolResources.execute_code: + return { + label: localize('com_ui_upload_code_environment'), + icon: , + }; + case EToolResources.context: + return { + label: localize('com_ui_upload_ocr_text'), + icon: , + }; + default: + return isProviderDocSupported + ? { + label: localize('com_ui_upload_provider'), + icon: , + } + : { + label: localize('com_ui_upload_image_input'), + icon: , + }; + } + }; - const validFileTypes = files.every(isValidProviderFile); - - _options.push({ - label: localize('com_ui_upload_provider'), - value: undefined, - icon: , - condition: validFileTypes, - }); - } else { - // Only show image upload option if all files are images and provider doesn't support documents - _options.push({ - label: localize('com_ui_upload_image_input'), - value: undefined, - icon: , - condition: files.every((file) => getFileType(file)?.startsWith('image/')), - }); - } - if (capabilities.fileSearchEnabled && fileSearchAllowedByAgent) { - _options.push({ - label: localize('com_ui_upload_file_search'), - value: EToolResources.file_search, - icon: , - }); - } - if (capabilities.codeEnabled && codeAllowedByAgent) { - _options.push({ - label: localize('com_ui_upload_code_environment'), - value: EToolResources.execute_code, - icon: , - }); - } - if (capabilities.contextEnabled) { - _options.push({ - label: localize('com_ui_upload_ocr_text'), - value: EToolResources.context, - icon: , - }); - } - - return _options; - }, [ - files, - localize, - provider, - endpoint, - endpointType, - capabilities, - useResponsesApi, - codeAllowedByAgent, - fileSearchAllowedByAgent, - ]); + const options = useMemo(() => getOptions(files), [getOptions, files]); if (!isVisible) { return null; } return ( - + !open && closeModal()}> - {options.map( - (option, index) => - option.condition !== false && ( - - ), - )} + {options.map((value) => { + const { label, icon } = getOptionMeta(value); + return ( + + ); + })} } /> diff --git a/client/src/components/Chat/Input/Files/DragDropWrapper.tsx b/client/src/components/Chat/Input/Files/DragDropWrapper.tsx index 4a01d8a2a1..2538315554 100644 --- a/client/src/components/Chat/Input/Files/DragDropWrapper.tsx +++ b/client/src/components/Chat/Input/Files/DragDropWrapper.tsx @@ -1,7 +1,7 @@ -import { useDragHelpers } from '~/hooks'; import DragDropOverlay from '~/components/Chat/Input/Files/DragDropOverlay'; import DragDropModal from '~/components/Chat/Input/Files/DragDropModal'; -import { DragDropProvider } from '~/Providers'; +import { DragDropProvider, UploadModalProvider } from '~/Providers'; +import { useDragHelpers } from '~/hooks'; import { cn } from '~/utils'; interface DragDropWrapperProps { @@ -9,10 +9,8 @@ interface DragDropWrapperProps { className?: string; } -export default function DragDropWrapper({ children, className }: DragDropWrapperProps) { - const { isOver, canDrop, drop, showModal, setShowModal, draggedFiles, handleOptionSelect } = - useDragHelpers(); - +function DragDropArea({ children, className }: DragDropWrapperProps) { + const { isOver, canDrop, drop } = useDragHelpers(); const isActive = canDrop && isOver; return ( @@ -20,14 +18,17 @@ export default function DragDropWrapper({ children, className }: DragDropWrapper {children} {/** Always render overlay to avoid mount/unmount overhead */} - - - + ); } + +export default function DragDropWrapper({ children, className }: DragDropWrapperProps) { + return ( + + + {children} + + + ); +} diff --git a/client/src/hooks/Files/index.ts b/client/src/hooks/Files/index.ts index 7fa4df5a17..28a46d66d0 100644 --- a/client/src/hooks/Files/index.ts +++ b/client/src/hooks/Files/index.ts @@ -5,6 +5,8 @@ export { default as useFileHandling, useFileHandlingNoChatContext } from './useF export { default as useFileDeletion } from './useFileDeletion'; export { default as useUpdateFiles } from './useUpdateFiles'; export { default as useDragHelpers } from './useDragHelpers'; +export { default as useUploadOptions } from './useUploadOptions'; +export { default as useFileUploadRouter } from './useFileUploadRouter'; export { default as useFileMap } from './useFileMap'; export { default as useSharePointPicker } from './useSharePointPicker'; export { default as useSharePointDownload } from './useSharePointDownload'; diff --git a/client/src/hooks/Files/useDragHelpers.ts b/client/src/hooks/Files/useDragHelpers.ts index 847884c988..40ea0754b2 100644 --- a/client/src/hooks/Files/useDragHelpers.ts +++ b/client/src/hooks/Files/useDragHelpers.ts @@ -1,83 +1,61 @@ -import { useState, useMemo, useCallback, useRef } from 'react'; +import { useRef, useMemo, useCallback } from 'react'; import { useDrop } from 'react-dnd'; +import { useRecoilValue } from 'recoil'; import { useToastContext } from '@librechat/client'; import { NativeTypes } from 'react-dnd-html5-backend'; import { useQueryClient } from '@tanstack/react-query'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; import { - Tools, QueryKeys, - Constants, - inferMimeType, - EToolResources, - EModelEndpoint, mergeFileConfig, - AgentCapabilities, resolveEndpointType, isAssistantsEndpoint, getEndpointFileConfig, - defaultAgentCapabilities, } from 'librechat-data-provider'; import type { DropTargetMonitor } from 'react-dnd'; import type * as t from 'librechat-data-provider'; -import store, { ephemeralAgentByConvoId } from '~/store'; -import useFileHandling from './useFileHandling'; -import { isEphemeralAgent } from '~/common'; +import useFileUploadRouter from './useFileUploadRouter'; +import { useUploadModalContext } from '~/Providers'; +import useUploadOptions from './useUploadOptions'; import useLocalize from '../useLocalize'; +import store from '~/store'; export default function useDragHelpers() { const queryClient = useQueryClient(); const { showToast } = useToastContext(); const localize = useLocalize(); - const [showModal, setShowModal] = useState(false); - const [draggedFiles, setDraggedFiles] = useState([]); const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; - const setEphemeralAgent = useSetRecoilState( - ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO), - ); const isAssistants = useMemo( () => isAssistantsEndpoint(conversation?.endpoint), [conversation?.endpoint], ); - const { handleFiles } = useFileHandling(); - - const handleOptionSelect = useCallback( - (toolResource: EToolResources | undefined) => { - /** File search is not automatically enabled to simulate legacy behavior */ - if (toolResource && toolResource !== EToolResources.file_search) { - setEphemeralAgent((prev) => ({ - ...prev, - [toolResource]: true, - })); - } - handleFiles(draggedFiles, toolResource); - setShowModal(false); - setDraggedFiles([]); - }, - [draggedFiles, handleFiles, setEphemeralAgent], - ); + const { getOptions } = useUploadOptions(); + const routeFiles = useFileUploadRouter(); + const { openModal } = useUploadModalContext(); /** Use refs to avoid re-creating the drop handler */ - const handleFilesRef = useRef(handleFiles); const conversationRef = useRef(conversation); + const getOptionsRef = useRef(getOptions); + const routeFilesRef = useRef(routeFiles); + const openModalRef = useRef(openModal); + const isAssistantsRef = useRef(isAssistants); - handleFilesRef.current = handleFiles; conversationRef.current = conversation; + getOptionsRef.current = getOptions; + routeFilesRef.current = routeFiles; + openModalRef.current = openModal; + isAssistantsRef.current = isAssistants; const handleDrop = useCallback( (item: { files: File[] }) => { /** Early block: leverage endpoint file config to prevent drag/drop on disabled endpoints */ const currentEndpoint = conversationRef.current?.endpoint ?? 'default'; const endpointsConfig = queryClient.getQueryData([QueryKeys.endpoints]); - - /** Get agent data from cache; if absent, provider-specific file config restrictions are bypassed client-side */ const agentId = conversationRef.current?.agent_id; const agent = agentId ? queryClient.getQueryData([QueryKeys.agent, agentId]) : undefined; - const currentEndpointType = resolveEndpointType( endpointsConfig, currentEndpoint, @@ -85,66 +63,35 @@ export default function useDragHelpers() { ); const cfg = queryClient.getQueryData([QueryKeys.fileConfig]); if (cfg) { - const mergedCfg = mergeFileConfig(cfg); const endpointCfg = getEndpointFileConfig({ - fileConfig: mergedCfg, + fileConfig: mergeFileConfig(cfg), endpoint: currentEndpoint, endpointType: currentEndpointType, }); if (endpointCfg?.disabled === true) { - showToast({ - message: localize('com_ui_attach_error_disabled'), - status: 'error', - }); + showToast({ message: localize('com_ui_attach_error_disabled'), status: 'error' }); return; } } - if (isAssistants) { - handleFilesRef.current(item.files); + /** Assistants do not use the upload-option flow */ + if (isAssistantsRef.current) { + routeFilesRef.current(item.files); return; } - const agentsConfig = endpointsConfig?.[EModelEndpoint.agents]; - const capabilities = agentsConfig?.capabilities ?? defaultAgentCapabilities; - const fileSearchEnabled = capabilities.includes(AgentCapabilities.file_search) === true; - const codeEnabled = capabilities.includes(AgentCapabilities.execute_code) === true; - const contextEnabled = capabilities.includes(AgentCapabilities.context) === true; - - let fileSearchAllowedByAgent = true; - let codeAllowedByAgent = true; - - if (agentId && !isEphemeralAgent(agentId)) { - if (agent) { - const agentTools = agent.tools as string[] | undefined; - fileSearchAllowedByAgent = agentTools?.includes(Tools.file_search) ?? false; - codeAllowedByAgent = agentTools?.includes(Tools.execute_code) ?? false; - } else { - fileSearchAllowedByAgent = false; - codeAllowedByAgent = false; - } - } - - /** Determine if dragged files are all images (enables the base image option) */ - const allImages = item.files.every((f) => - inferMimeType(f.name, f.type)?.startsWith('image/'), - ); - - const shouldShowModal = - allImages || - (fileSearchEnabled && fileSearchAllowedByAgent) || - (codeEnabled && codeAllowedByAgent) || - contextEnabled; - - if (!shouldShowModal) { - // Fallback: directly handle files without showing modal - handleFilesRef.current(item.files); + const options = getOptionsRef.current(item.files); + if (options.length === 0) { + showToast({ message: localize('com_error_files_unsupported'), status: 'error' }); return; } - setDraggedFiles(item.files); - setShowModal(true); + if (options.length === 1) { + routeFilesRef.current(item.files, options[0]); + return; + } + openModalRef.current(item.files); }, - [isAssistants, queryClient, showToast, localize], + [queryClient, showToast, localize], ); const [{ canDrop, isOver }, drop] = useDrop( @@ -152,23 +99,13 @@ export default function useDragHelpers() { accept: [NativeTypes.FILE], drop: handleDrop, canDrop: () => true, - collect: (monitor: DropTargetMonitor) => { - /** Optimize collect to reduce re-renders */ - const isOver = monitor.isOver(); - const canDrop = monitor.canDrop(); - return { isOver, canDrop }; - }, + collect: (monitor: DropTargetMonitor) => ({ + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + }), }), [handleDrop], ); - return { - canDrop, - isOver, - drop, - showModal, - setShowModal, - draggedFiles, - handleOptionSelect, - }; + return { canDrop, isOver, drop }; } diff --git a/client/src/hooks/Files/useFileUploadRouter.ts b/client/src/hooks/Files/useFileUploadRouter.ts new file mode 100644 index 0000000000..906b830947 --- /dev/null +++ b/client/src/hooks/Files/useFileUploadRouter.ts @@ -0,0 +1,31 @@ +import { useCallback } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { Constants, EToolResources } from 'librechat-data-provider'; +import store, { ephemeralAgentByConvoId } from '~/store'; +import useFileHandling from './useFileHandling'; + +/** + * Returns a function that attaches files to a chosen upload destination, enabling the + * matching ephemeral-agent capability first (file search is left for explicit opt-in to + * preserve legacy behavior). Shared by the paste, drag, and modal flows. + */ +export default function useFileUploadRouter() { + const { handleFiles } = useFileHandling(); + const conversation = useRecoilValue(store.conversationByIndex(0)) || undefined; + const setEphemeralAgent = useSetRecoilState( + ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO), + ); + + return useCallback( + (files: File[], toolResource?: EToolResources) => { + if (toolResource && toolResource !== EToolResources.file_search) { + setEphemeralAgent((prev) => ({ + ...prev, + [toolResource]: true, + })); + } + handleFiles(files, toolResource); + }, + [handleFiles, setEphemeralAgent], + ); +} diff --git a/client/src/hooks/Files/useUploadOptions.ts b/client/src/hooks/Files/useUploadOptions.ts new file mode 100644 index 0000000000..c82176f531 --- /dev/null +++ b/client/src/hooks/Files/useUploadOptions.ts @@ -0,0 +1,80 @@ +import { useCallback } from 'react'; +import { useRecoilValue } from 'recoil'; +import { + Tools, + Constants, + mergeFileConfig, + getEndpointFileConfig, + defaultAgentCapabilities, +} from 'librechat-data-provider'; +import type { EToolResources } from 'librechat-data-provider'; +import useAgentToolPermissions from '~/hooks/Agents/useAgentToolPermissions'; +import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities'; +import useGetAgentsConfig from '~/hooks/Agents/useGetAgentsConfig'; +import { useGetFileConfig } from '~/data-provider'; +import { ephemeralAgentByConvoId } from '~/store'; +import { getViableUploadOptions } from '~/utils'; +import { useDragDropContext } from '~/Providers'; +import { isEphemeralAgent } from '~/common'; + +/** + * Resolves which upload destinations a file set can be routed to, plus whether uploads are + * disabled for the endpoint. Shared by the paste, drag, and modal flows so they decide + * consistently from one source. + */ +export default function useUploadOptions() { + const { conversationId, agentId, endpoint, endpointType, useResponsesApi } = useDragDropContext(); + const { agentsConfig } = useGetAgentsConfig(); + const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities); + const ephemeralAgent = useRecoilValue( + ephemeralAgentByConvoId(conversationId ?? Constants.NEW_CONVO), + ); + const { provider, tools } = useAgentToolPermissions(agentId, ephemeralAgent); + const { data: fileConfig = null } = useGetFileConfig({ + select: (data) => mergeFileConfig(data), + }); + + /** + * Tools are offerable unless a saved agent omits them; in direct/ephemeral chats selecting + * one enables the ephemeral capability, matching the original drag-and-drop behavior. + */ + const isSavedAgent = agentId != null && agentId !== '' && !isEphemeralAgent(agentId); + const fileSearchAllowedByAgent = !isSavedAgent || (tools?.includes(Tools.file_search) ?? false); + const codeAllowedByAgent = !isSavedAgent || (tools?.includes(Tools.execute_code) ?? false); + + const endpointFileConfig = getEndpointFileConfig({ fileConfig, endpoint, endpointType }); + const uploadsDisabled = endpointFileConfig.disabled === true; + const endpointSupportedMimeTypes = endpointFileConfig.supportedMimeTypes; + + const getOptions = useCallback( + (files: File[]): (EToolResources | undefined)[] => + getViableUploadOptions(files, { + provider, + endpoint, + endpointType, + useResponsesApi, + fileSearchEnabled: capabilities.fileSearchEnabled, + codeEnabled: capabilities.codeEnabled, + contextEnabled: capabilities.contextEnabled, + fileSearchAllowedByAgent, + codeAllowedByAgent, + fileConfig, + endpointSupportedMimeTypes, + }), + [ + provider, + endpoint, + endpointType, + useResponsesApi, + capabilities.fileSearchEnabled, + capabilities.codeEnabled, + capabilities.contextEnabled, + fileSearchAllowedByAgent, + codeAllowedByAgent, + fileConfig, + endpointSupportedMimeTypes, + ], + ); + + return { getOptions, uploadsDisabled }; +} diff --git a/client/src/hooks/Input/useTextarea.ts b/client/src/hooks/Input/useTextarea.ts index 0e542705ee..f48db60277 100644 --- a/client/src/hooks/Input/useTextarea.ts +++ b/client/src/hooks/Input/useTextarea.ts @@ -1,6 +1,8 @@ import { useEffect, useRef, useCallback, useMemo } from 'react'; import debounce from 'lodash/debounce'; +import { useToastContext } from '@librechat/client'; import { useRecoilValue, useRecoilState } from 'recoil'; +import { EToolResources, isAssistantsEndpoint } from 'librechat-data-provider'; import type { TEndpointOption } from 'librechat-data-provider'; import type { KeyboardEvent } from 'react'; import { @@ -18,11 +20,13 @@ import { } from '~/utils'; import { useAssistantsMapContext } from '~/Providers/AssistantsMapContext'; import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; +import useFileUploadRouter from '~/hooks/Files/useFileUploadRouter'; import { useAgentsMapContext } from '~/Providers/AgentsMapContext'; import useGetSender from '~/hooks/Conversations/useGetSender'; -import useFileHandling from '~/hooks/Files/useFileHandling'; +import useUploadOptions from '~/hooks/Files/useUploadOptions'; import { useInteractionHealthCheck } from '~/data-provider'; import { useChatContext } from '~/Providers/ChatContext'; +import { useUploadModalContext } from '~/Providers'; import { globalAudioId } from '~/common'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -46,7 +50,10 @@ export default function useTextarea({ const getSender = useGetSender(); const isComposing = useRef(false); const agentsMap = useAgentsMapContext(); - const { handleFiles } = useFileHandling(); + const { showToast } = useToastContext(); + const { getOptions: getUploadOptions, uploadsDisabled } = useUploadOptions(); + const routeFiles = useFileUploadRouter(); + const { openModal } = useUploadModalContext(); const assistantMap = useAssistantsMapContext(); const checkHealth = useInteractionHealthCheck(); const enterToSend = useRecoilValue(store.enterToSend); @@ -285,10 +292,47 @@ export default function useTextarea({ }); timestampedFiles.push(newFile); } - handleFiles(timestampedFiles); + + if (uploadsDisabled) { + showToast({ message: localize('com_ui_attach_error_disabled'), status: 'error' }); + setFilesLoading(false); + return; + } + + /** Assistants use their own upload path; bypass option resolution like drag-and-drop does */ + if (isAssistantsEndpoint(conversation?.endpoint)) { + routeFiles(timestampedFiles); + return; + } + + const options = getUploadOptions(timestampedFiles); + if (options.length === 0) { + showToast({ message: localize('com_error_files_unsupported'), status: 'error' }); + setFilesLoading(false); + return; + } + if (options.length === 1) { + routeFiles(timestampedFiles, options[0]); + if (options[0] === EToolResources.context) { + showToast({ message: localize('com_ui_file_attached_as_text'), status: 'info' }); + } + return; + } + setFilesLoading(false); + openModal(timestampedFiles); } }, - [handleFiles, setFilesLoading, textAreaRef], + [ + localize, + showToast, + openModal, + routeFiles, + conversation, + textAreaRef, + uploadsDisabled, + setFilesLoading, + getUploadOptions, + ], ); return { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b2debb8de9..e7af1f886a 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -373,6 +373,7 @@ "com_error_files_dupe": "Duplicate file detected.", "com_error_files_empty": "Empty files are not allowed.", "com_error_files_process": "An error occurred while processing the file.", + "com_error_files_unsupported": "This file type can't be attached here.", "com_error_files_upload": "An error occurred while uploading the file.", "com_error_files_upload_canceled": "The file upload request was canceled. Note: the file upload may still be processing and will need to be manually deleted.", "com_error_files_upload_too_large": "The file is too large. Please upload a file smaller than {{0}} MB", @@ -1147,6 +1148,7 @@ "com_ui_field_max_length": "{{field}} must be less than {{length}} characters", "com_ui_field_required": "This field is required", "com_ui_file": "File", + "com_ui_file_attached_as_text": "Attached as text", "com_ui_file_input_avatar_label": "File input for avatar", "com_ui_file_modified": "Modified", "com_ui_file_size": "File Size", diff --git a/client/src/utils/__tests__/getViableUploadOptions.spec.ts b/client/src/utils/__tests__/getViableUploadOptions.spec.ts new file mode 100644 index 0000000000..5c1a7ff74e --- /dev/null +++ b/client/src/utils/__tests__/getViableUploadOptions.spec.ts @@ -0,0 +1,147 @@ +import { EToolResources } from 'librechat-data-provider'; +import type { FileConfig } from 'librechat-data-provider'; +import { getViableUploadOptions, type UploadOptionContext } from '../files'; + +const XLSX = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'; + +/** context accepts plain text + csv (text), pdf + xlsx (ocr); nothing else */ +const fileConfig = { + text: { supportedMimeTypes: [/^text\/(plain|csv)$/] }, + ocr: { + supportedMimeTypes: [ + /^application\/pdf$/, + /^application\/vnd\.openxmlformats-officedocument\.spreadsheetml\.sheet$/, + ], + }, + stt: { supportedMimeTypes: [] }, +} as unknown as FileConfig; + +const baseCtx = (over: Partial = {}): UploadOptionContext => ({ + provider: 'anthropic', + endpoint: 'anthropic', + endpointType: 'anthropic', + useResponsesApi: false, + fileSearchEnabled: true, + codeEnabled: true, + contextEnabled: true, + fileSearchAllowedByAgent: true, + codeAllowedByAgent: true, + fileConfig, + ...over, +}); + +const file = (type: string, name: string) => new File(['x'], name, { type }); + +describe('getViableUploadOptions', () => { + it('returns empty for no files', () => { + expect(getViableUploadOptions([], baseCtx())).toEqual([]); + }); + + it('returns empty when a file type cannot be inferred', () => { + expect(getViableUploadOptions([file('', 'mystery.unknownext')], baseCtx())).toEqual([]); + }); + + describe('Anthropic (PDF/image only for provider attach)', () => { + it('routes a spreadsheet to code + text, not the provider', () => { + expect(getViableUploadOptions([file(XLSX, 'report.xlsx')], baseCtx())).toEqual([ + EToolResources.execute_code, + EToolResources.context, + ]); + }); + + it('offers every destination for a PDF', () => { + expect(getViableUploadOptions([file('application/pdf', 'doc.pdf')], baseCtx())).toEqual([ + undefined, + EToolResources.file_search, + EToolResources.execute_code, + EToolResources.context, + ]); + }); + + it('yields a single option for a zip (code only) so it can auto-route', () => { + expect(getViableUploadOptions([file('application/zip', 'a.zip')], baseCtx())).toEqual([ + EToolResources.execute_code, + ]); + }); + + it('attaches a PDF directly to the provider when capabilities are off', () => { + const ctx = baseCtx({ fileSearchEnabled: false, codeEnabled: false, contextEnabled: false }); + expect(getViableUploadOptions([file('application/pdf', 'doc.pdf')], ctx)).toEqual([ + undefined, + ]); + }); + + it('returns nothing for a spreadsheet when no capabilities are enabled', () => { + const ctx = baseCtx({ fileSearchEnabled: false, codeEnabled: false, contextEnabled: false }); + expect(getViableUploadOptions([file(XLSX, 'report.xlsx')], ctx)).toEqual([]); + }); + }); + + describe('provider-specific direct attachment', () => { + it('lets Google attach video directly', () => { + const ctx = baseCtx({ + provider: 'google', + endpoint: 'google', + endpointType: 'google', + fileSearchEnabled: false, + codeEnabled: false, + contextEnabled: false, + }); + expect(getViableUploadOptions([file('video/mp4', 'clip.mp4')], ctx)).toEqual([undefined]); + }); + + it('does not let Anthropic attach video directly', () => { + const ctx = baseCtx({ + fileSearchEnabled: false, + codeEnabled: false, + contextEnabled: false, + }); + expect(getViableUploadOptions([file('video/mp4', 'clip.mp4')], ctx)).toEqual([]); + }); + + it('lets Bedrock attach a spreadsheet directly via its document allowlist', () => { + const ctx = baseCtx({ + provider: 'bedrock', + endpoint: 'bedrock', + endpointType: 'bedrock', + fileSearchEnabled: false, + codeEnabled: false, + contextEnabled: false, + }); + expect(getViableUploadOptions([file(XLSX, 'report.xlsx')], ctx)).toEqual([undefined]); + }); + + it('honors a permissive custom endpoint config for direct attach', () => { + const ctx = baseCtx({ + provider: 'MyGateway', + endpoint: 'MyGateway', + endpointType: 'custom', + fileSearchEnabled: false, + codeEnabled: false, + contextEnabled: false, + endpointSupportedMimeTypes: [/.*/], + }); + expect(getViableUploadOptions([file(XLSX, 'report.xlsx')], ctx)).toEqual([undefined]); + }); + + it('does not treat a non-permissive custom config as broad provider support', () => { + const ctx = baseCtx({ + provider: 'MyGateway', + endpoint: 'MyGateway', + endpointType: 'custom', + fileSearchEnabled: false, + codeEnabled: false, + contextEnabled: false, + endpointSupportedMimeTypes: [/^application\/pdf$/], + }); + expect(getViableUploadOptions([file(XLSX, 'report.xlsx')], ctx)).toEqual([]); + }); + }); + + it('drops an option when the agent disallows it', () => { + const ctx = baseCtx({ contextEnabled: false, fileSearchEnabled: false }); + expect(getViableUploadOptions([file(XLSX, 'report.xlsx')], ctx)).toEqual([ + EToolResources.execute_code, + ]); + }); +}); diff --git a/client/src/utils/files.ts b/client/src/utils/files.ts index 536b340bf3..05ebfaffba 100644 --- a/client/src/utils/files.ts +++ b/client/src/utils/files.ts @@ -8,10 +8,17 @@ import { } from '@librechat/client'; import { megabyte, + Providers, QueryKeys, inferMimeType, excelMimeTypes, EToolResources, + EModelEndpoint, + retrievalMimeTypes, + isBedrockDocumentType, + isPermissiveMimeConfig, + codeInterpreterMimeTypes, + isDocumentSupportedProvider, fileConfig as defaultFileConfig, } from 'librechat-data-provider'; import type { TFile, EndpointFileConfig, FileConfig } from 'librechat-data-provider'; @@ -318,6 +325,111 @@ export const validateFiles = ({ return true; }; +export type UploadOptionContext = { + provider?: string | null; + endpoint?: string | null; + endpointType?: string | null; + useResponsesApi?: boolean; + fileSearchEnabled: boolean; + codeEnabled: boolean; + contextEnabled: boolean; + fileSearchAllowedByAgent: boolean; + codeAllowedByAgent: boolean; + fileConfig: FileConfig | null; + endpointSupportedMimeTypes?: RegExp[]; +}; + +const isProviderAttachType = (type: string, ctx: UploadOptionContext): boolean => { + let currentProvider = (ctx.provider || ctx.endpoint) ?? ''; + if (currentProvider.toLowerCase() === Providers.OPENROUTER) { + currentProvider = Providers.OPENROUTER; + } + const isAzureWithResponsesApi = + (currentProvider === EModelEndpoint.azureOpenAI || + ctx.endpointType === EModelEndpoint.azureOpenAI) && + ctx.useResponsesApi === true; + + if ( + isDocumentSupportedProvider(ctx.endpointType) || + isDocumentSupportedProvider(currentProvider) || + isAzureWithResponsesApi + ) { + /** Custom endpoints that the admin opened up (permissive config) honor that allowlist, + * matching the file picker; an inherited default config is not treated as opened up. */ + if ( + ctx.endpointType === EModelEndpoint.custom && + ctx.endpointSupportedMimeTypes != null && + isPermissiveMimeConfig(ctx.endpointSupportedMimeTypes) + ) { + return checkType(type, ctx.endpointSupportedMimeTypes); + } + if (currentProvider === EModelEndpoint.google || currentProvider === Providers.OPENROUTER) { + return ( + type.startsWith('image/') || + type.startsWith('video/') || + type.startsWith('audio/') || + type === 'application/pdf' + ); + } + if (currentProvider === Providers.BEDROCK || ctx.endpointType === EModelEndpoint.bedrock) { + return type.startsWith('image/') || isBedrockDocumentType(type); + } + return type.startsWith('image/') || type === 'application/pdf'; + } + return type.startsWith('image/'); +}; + +const isContextType = (type: string, fileConfig: FileConfig | null): boolean => + checkType(type, [ + ...(fileConfig?.text?.supportedMimeTypes || []), + ...(fileConfig?.ocr?.supportedMimeTypes || []), + ...(fileConfig?.stt?.supportedMimeTypes || []), + ]); + +/** + * Upload destinations a file set can be routed to, given the active endpoint and agent + * capabilities. `undefined` is direct provider attachment; the rest are tool resources. + * Each option requires every file to be valid for it, so the caller can decide between + * auto-routing (one option), prompting (multiple), or rejecting (none). + */ +export const getViableUploadOptions = ( + fileList: File[], + ctx: UploadOptionContext, +): (EToolResources | undefined)[] => { + if (fileList.length === 0) { + return []; + } + const types = fileList.map((file) => inferMimeType(file.name, file.type)); + if (types.some((type) => !type)) { + return []; + } + const every = (predicate: (type: string) => boolean) => + types.every((type) => predicate(type as string)); + + const options: (EToolResources | undefined)[] = []; + if (every((type) => isProviderAttachType(type, ctx))) { + options.push(undefined); + } + if ( + ctx.fileSearchEnabled && + ctx.fileSearchAllowedByAgent && + every((type) => !type.startsWith('image/') && checkType(type, retrievalMimeTypes)) + ) { + options.push(EToolResources.file_search); + } + if ( + ctx.codeEnabled && + ctx.codeAllowedByAgent && + every((type) => checkType(type, codeInterpreterMimeTypes)) + ) { + options.push(EToolResources.execute_code); + } + if (ctx.contextEnabled && every((type) => isContextType(type, ctx.fileConfig))) { + options.push(EToolResources.context); + } + return options; +}; + export function sortPagesByRelevance( pages: number[], pageRelevance: Record,