From edeb1ecc2c4963c473415fd15d0d70ebf3031b1b Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Fri, 26 Jun 2026 09:00:07 -0700 Subject: [PATCH] =?UTF-8?q?=F0=9F=93=8B=20fix:=20Route=20Clipboard=20Paste?= =?UTF-8?q?=20Through=20Upload=20Options=20(#13957)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 fix: route clipboard paste through upload-option guards Pasting a file skipped the composer's attachment guards, so unsupported types such as csv and xlsx reached the provider as document blocks and were rejected. Paste, drag, and the upload modal now share getViableUploadOptions to decide routing: zero viable destinations shows a toast, one auto-routes, several open the upload-type modal. * 🐛 fix: key ephemeral agent state by NEW_CONVO in upload-option flow useFileUploadRouter writes ephemeral capability state under `conversationId ?? Constants.NEW_CONVO`, but useUploadOptions and DragDropModal read it under `?? ''`, so on a new conversation the option resolver missed capabilities enabled by auto-routing. Align the reads on Constants.NEW_CONVO. * 🐛 fix: harden paste upload routing for assistants, custom endpoints, and toasts Bypass option resolution for Assistants endpoints on paste, matching drag-and-drop, so non-image assistant uploads use the assistants upload path instead of mis-routing to context or the unsupported toast. Honor a custom endpoint's configured supportedMimeTypes for direct provider attach instead of hardcoding image and PDF. Stop asserting upload success before validation runs; the single-route notice is now an informational "Attached as text" for the text-extraction case only. * 🐛 fix: refine paste upload routing for direct chats, custom endpoints, and disabled uploads Restore Code Interpreter and File Search options in direct and ephemeral chats by defaulting their permissions to allowed unless a saved agent omits the tool; selecting one still enables the ephemeral capability. Treat a custom endpoint as broad provider support only when its file config is permissive (matching the file picker), so an inherited default no longer offers zip/audio/video for direct attach. Short-circuit paste with the disabled-upload error before resolving options or opening the modal. --- client/src/Providers/UploadModalContext.tsx | 43 ++++ client/src/Providers/index.ts | 1 + .../Chat/Input/Files/DragDropModal.tsx | 197 ++++++------------ .../Chat/Input/Files/DragDropWrapper.tsx | 29 +-- client/src/hooks/Files/index.ts | 2 + client/src/hooks/Files/useDragHelpers.ts | 135 ++++-------- client/src/hooks/Files/useFileUploadRouter.ts | 31 +++ client/src/hooks/Files/useUploadOptions.ts | 80 +++++++ client/src/hooks/Input/useTextarea.ts | 52 ++++- client/src/locales/en/translation.json | 2 + .../__tests__/getViableUploadOptions.spec.ts | 147 +++++++++++++ client/src/utils/files.ts | 112 ++++++++++ 12 files changed, 583 insertions(+), 248 deletions(-) create mode 100644 client/src/Providers/UploadModalContext.tsx create mode 100644 client/src/hooks/Files/useFileUploadRouter.ts create mode 100644 client/src/hooks/Files/useUploadOptions.ts create mode 100644 client/src/utils/__tests__/getViableUploadOptions.spec.ts 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,