feat: implement Anthropic native PDF support with document preservation

- Add comprehensive debug logging throughout PDF processing pipeline
- Refactor attachment processing to separate image and document handling
- Create distinct addImageURLs(), addDocuments(), and processAttachments() methods
- Fix critical bugs in stream handling and parameter passing
- Add streamToBuffer utility for proper stream-to-buffer conversion
- Remove api/agents submodule from repository

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andres Restrepo 2025-08-10 13:25:25 -05:00
parent 007570b5c6
commit 6605b6c800
53 changed files with 630 additions and 145 deletions

View file

@ -1,9 +1,9 @@
import { defaultNS, resources } from '~/locales/i18n';
declare module 'i18next' {
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: typeof resources.en;
strictKeyChecks: true
}
}
interface CustomTypeOptions {
defaultNS: typeof defaultNS;
resources: typeof resources.en;
strictKeyChecks: true;
}
}

View file

@ -156,7 +156,6 @@ test('renders registration form', () => {
);
});
// eslint-disable-next-line jest/no-commented-out-tests
// test('calls registerUser.mutate on registration', async () => {
// const mutate = jest.fn();
// const { getByTestId, getByRole, history } = setup({

View file

@ -36,6 +36,7 @@ function AttachFileChat({ disableInputs }: { disableInputs: boolean }) {
disabled={disableInputs}
conversationId={conversationId}
endpointFileConfig={endpointFileConfig}
endpoint={endpoint}
/>
);
}

View file

@ -1,7 +1,7 @@
import React, { useRef, useState, useMemo } from 'react';
import * as Ariakit from '@ariakit/react';
import { useSetRecoilState } from 'recoil';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
import { FileSearch, ImageUpIcon, TerminalSquareIcon, FileType2Icon, FileText } from 'lucide-react';
import { FileUpload, TooltipAnchor, DropdownPopup, AttachmentIcon } from '@librechat/client';
import { EToolResources, EModelEndpoint, defaultAgentCapabilities } from 'librechat-data-provider';
import type { EndpointFileConfig } from 'librechat-data-provider';
@ -13,9 +13,15 @@ interface AttachFileMenuProps {
conversationId: string;
disabled?: boolean | null;
endpointFileConfig?: EndpointFileConfig;
endpoint?: string | null;
}
const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: AttachFileMenuProps) => {
const AttachFileMenu = ({
disabled,
conversationId,
endpointFileConfig,
endpoint,
}: AttachFileMenuProps) => {
const localize = useLocalize();
const isUploadDisabled = disabled ?? false;
const inputRef = useRef<HTMLInputElement>(null);
@ -23,7 +29,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(conversationId));
const [toolResource, setToolResource] = useState<EToolResources | undefined>();
const { handleFileChange } = useFileHandling({
overrideEndpoint: EModelEndpoint.agents,
overrideEndpoint: endpoint === EModelEndpoint.anthropic ? undefined : EModelEndpoint.agents,
overrideEndpointFileConfig: endpointFileConfig,
});
@ -34,12 +40,18 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
* */
const capabilities = useAgentCapabilities(agentsConfig?.capabilities ?? defaultAgentCapabilities);
const handleUploadClick = (isImage?: boolean) => {
const handleUploadClick = (fileType?: 'image' | 'document') => {
if (!inputRef.current) {
return;
}
inputRef.current.value = '';
inputRef.current.accept = isImage === true ? 'image/*' : '';
if (fileType === 'image') {
inputRef.current.accept = 'image/*';
} else if (fileType === 'document') {
inputRef.current.accept = '.pdf,application/pdf';
} else {
inputRef.current.accept = '';
}
inputRef.current.click();
inputRef.current.accept = '';
};
@ -50,13 +62,26 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
label: localize('com_ui_upload_image_input'),
onClick: () => {
setToolResource(undefined);
handleUploadClick(true);
handleUploadClick('image');
},
icon: <ImageUpIcon className="icon-md" />,
},
];
if (capabilities.ocrEnabled) {
// Add document upload option for Anthropic endpoints
if (endpoint === EModelEndpoint.anthropic) {
items.push({
label: 'Upload Document',
onClick: () => {
setToolResource(undefined);
handleUploadClick('document');
},
icon: <FileText className="icon-md" />,
});
}
// Hide OCR and File Search for Anthropic endpoints (native PDF support makes these irrelevant)
if (capabilities.ocrEnabled && endpoint !== EModelEndpoint.anthropic) {
items.push({
label: localize('com_ui_upload_ocr_text'),
onClick: () => {
@ -67,7 +92,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
});
}
if (capabilities.fileSearchEnabled) {
if (capabilities.fileSearchEnabled && endpoint !== EModelEndpoint.anthropic) {
items.push({
label: localize('com_ui_upload_file_search'),
onClick: () => {
@ -95,7 +120,7 @@ const AttachFileMenu = ({ disabled, conversationId, endpointFileConfig }: Attach
}
return items;
}, [capabilities, localize, setToolResource, setEphemeralAgent]);
}, [capabilities, localize, setToolResource, setEphemeralAgent, endpoint]);
const menuTrigger = (
<TooltipAnchor

View file

@ -1,12 +1,6 @@
export default function DragDropOverlay() {
return (
<div
className="bg-surface-primary/85 fixed inset-0 z-[9999] flex flex-col items-center justify-center
gap-2 text-text-primary
backdrop-blur-[4px] transition-all duration-200
ease-in-out animate-in fade-in
zoom-in-95 hover:backdrop-blur-sm"
>
<div className="bg-surface-primary/85 fixed inset-0 z-[9999] flex flex-col items-center justify-center gap-2 text-text-primary backdrop-blur-[4px] transition-all duration-200 ease-in-out animate-in fade-in zoom-in-95 hover:backdrop-blur-sm">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 132 108"

View file

@ -39,7 +39,7 @@ export default function StreamAudio({ index = 0 }) {
const { pauseGlobalAudio } = usePauseGlobalAudio();
const { conversationId: paramId } = useParams();
const queryParam = paramId === 'new' ? paramId : latestMessage?.conversationId ?? paramId ?? '';
const queryParam = paramId === 'new' ? paramId : (latestMessage?.conversationId ?? paramId ?? '');
const queryClient = useQueryClient();
const getMessages = useCallback(

View file

@ -33,7 +33,7 @@ export const data: TModelSpec[] = [
iconURL: EModelEndpoint.openAI, // Allow using project-included icons
preset: {
chatGptLabel: 'Vision Helper',
greeting: 'What\'s up!!',
greeting: "What's up!!",
endpoint: EModelEndpoint.openAI,
model: 'gpt-4-turbo',
promptPrefix:

View file

@ -55,7 +55,7 @@ const MenuItem: FC<MenuItemProps> = ({
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className={cn('flex items-center gap-1 ')}>
<div className={cn('flex items-center gap-1')}>
{icon != null ? icon : null}
<div className={cn('truncate', textClassName)}>
{title}
@ -72,7 +72,7 @@ const MenuItem: FC<MenuItemProps> = ({
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block "
className="icon-md block"
>
<path
fillRule="evenodd"

View file

@ -15,7 +15,7 @@ export default function ProgressCircle({
className="absolute left-1/2 top-1/2 h-[23px] w-[23px] -translate-x-1/2 -translate-y-1/2 text-brand-purple"
>
<circle
className="origin-[50%_50%] -rotate-90 stroke-brand-purple/25 dark:stroke-brand-purple/50"
className="stroke-brand-purple/25 dark:stroke-brand-purple/50 origin-[50%_50%] -rotate-90"
strokeWidth="7.826086956521739"
fill="transparent"
r={radius}

View file

@ -3,7 +3,7 @@ import { useRecoilValue } from 'recoil';
import { useMessageProcess } from '~/hooks';
import type { TMessageProps } from '~/common';
import MessageRender from './ui/MessageRender';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import { cn } from '~/utils';
import store from '~/store';
@ -73,7 +73,7 @@ export default function Message(props: TMessageProps) {
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<MessageRender {...props} />
</div>
)}

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useMessageProcess } from '~/hooks';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import MultiMessage from '~/components/Chat/Messages/MultiMessage';
import ContentRender from './ContentRender';
@ -64,7 +64,7 @@ export default function MessageContent(props: TMessageProps) {
</div>
</div>
) : (
<div className="m-auto justify-center p-4 py-2 md:gap-6 ">
<div className="m-auto justify-center p-4 py-2 md:gap-6">
<ContentRender {...props} />
</div>
)}

View file

@ -187,8 +187,7 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
value={searchValue}
onChange={handleSearch}
placeholder={localize('com_nav_plugin_search')}
className="
text-token-text-primary flex rounded-md border border-border-heavy bg-surface-tertiary py-2 pl-10 pr-2"
className="text-token-text-primary flex rounded-md border border-border-heavy bg-surface-tertiary py-2 pl-10 pr-2"
/>
</div>
</div>

View file

@ -9,7 +9,7 @@ type TPluginTooltipProps = {
function PluginTooltip({ content, position }: TPluginTooltipProps) {
return (
<HoverCardPortal>
<HoverCardContent side={position} className="w-80 ">
<HoverCardContent side={position} className="w-80">
<div className="space-y-2">
<div className="text-sm text-gray-600 dark:text-gray-300">
<div dangerouslySetInnerHTML={{ __html: content }} />

View file

@ -2,7 +2,7 @@ import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
import store from '~/store';

View file

@ -33,9 +33,7 @@ export default function ActionsAuth({ disableOAuth }: { disableOAuth?: boolean }
</label>
</div>
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">
<div className="h-9 grow px-3 py-2">
{localize(getAuthLocalizationKey(type))}
</div>
<div className="h-9 grow px-3 py-2">{localize(getAuthLocalizationKey(type))}</div>
<div className="bg-token-border-medium w-px"></div>
<button type="button" color="neutral" className="flex items-center gap-2 px-3">
<svg

View file

@ -31,7 +31,7 @@ export default function useAddedHelpers({
store.messagesSiblingIdxFamily(latestMessage?.parentMessageId ?? null),
);
const queryParam = paramId === 'new' ? paramId : conversation?.conversationId ?? paramId ?? '';
const queryParam = paramId === 'new' ? paramId : (conversation?.conversationId ?? paramId ?? '');
const setMessages = useCallback(
(messages: TMessage[]) => {

View file

@ -48,10 +48,11 @@ export default function useExportConversation({
const { conversationId: paramId } = useParams();
const getMessageTree = useCallback(() => {
const queryParam = paramId === 'new' ? paramId : conversation?.conversationId ?? paramId ?? '';
const queryParam =
paramId === 'new' ? paramId : (conversation?.conversationId ?? paramId ?? '');
const messages = queryClient.getQueryData<TMessage[]>([QueryKeys.messages, queryParam]) ?? [];
const dataTree = buildTree({ messages });
return dataTree?.length === 0 ? null : dataTree ?? null;
return dataTree?.length === 0 ? null : (dataTree ?? null);
}, [paramId, conversation?.conversationId, queryClient]);
const getMessageText = (message: TMessage | undefined, format = 'text') => {

View file

@ -33,9 +33,8 @@ export default function useContentHandler({ setMessages, getMessages }: TUseCont
const _messages = getMessages();
const messages =
_messages
?.filter((m) => m.messageId !== messageId)
.map((msg) => ({ ...msg, thread_id })) ?? [];
_messages?.filter((m) => m.messageId !== messageId).map((msg) => ({ ...msg, thread_id })) ??
[];
const userMessage = messages[messages.length - 1] as TMessage | undefined;
const { initialResponse } = submission;

View file

@ -1,4 +1,3 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck
import { EModelEndpoint, ImageDetail } from 'librechat-data-provider';
import type { ConversationData } from 'librechat-data-provider';
@ -98,7 +97,7 @@ export const convoData: ConversationData = {
promptPrefix: null,
resendFiles: false,
temperature: 1,
title: 'Write Einstein\'s Famous Equation in LaTeX',
title: "Write Einstein's Famous Equation in LaTeX",
top_p: 1,
updatedAt,
},