From a2ad0aa0c8cce6e49dda58db5b8c73bc668e5375 Mon Sep 17 00:00:00 2001 From: Anubhav Anand <76263415+i-anubhav-anand@users.noreply.github.com> Date: Sat, 15 Aug 2026 22:17:31 +0530 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=90=20feat:=20Allow=20Promptless=20Sen?= =?UTF-8?q?ds=20When=20Files=20Are=20Attached=20(#13717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ feat: Allow sending file attachments without a text message When an agent asks the user to upload a document, the user could attach the file but still had to type a placeholder message ("OK", "Here is the file") before the send button enabled and the submit guard let the message through. Attachments now count as submittable content: - New isSubmittableMessage(text, fileCount) util: non-whitespace text OR at least one attached file. - ask() in useChatFunctions uses it instead of bailing on empty text, so an empty draft with attached files submits. - SendButton receives the attached file count and enables accordingly. - ChatForm only marks the text field as required when no files are attached, so react-hook-form validation no longer blocks handleSubmit. Submitting an empty draft with no attachments is still rejected at all three layers. Fixes #13646 * Address review: support replayed file-only turns + drop empty vision text - ask(): count replayed attachments (overrideFiles) in the submittable check and skip it entirely for regenerate, so a file-only message can be regenerated or saved-and-resubmitted instead of being rejected as empty. - formatVisionMessage(): omit the text content part when the message text is empty. Anthropic rejects empty text content blocks with HTTP 400, and an empty block adds nothing for other providers; image-only sends now format cleanly. Added formatMessages tests for with-text and image-only (Anthropic + other) cases. * Address review: keep attachment-only turns valid for providers, answer mode, and titles - formatMessage: substitute minimal text when a user turn carries files but no inline content, so Anthropic does not reject an empty user message for RAG or code-environment attachments. - assistants chatV1: send the same stand-in for attachment-only Threads messages, which reject an empty body. The persisted message keeps empty text. - ChatForm: attachments no longer make an empty draft submittable in answer mode, where submitText consumes the click without answering or sending. - agents request: seed title generation from attachment filenames when the turn has no text, so immediate-mode titles are not invented from an empty string. - useChatFunctions.regenerate.spec: mock the utils barrel over the real module so new exports resolve. * Cover the agents path for attachment-only turns AgentClient formats its payload with the SDK's formatMessage, not the local one, so the earlier guard missed the endpoint the feature actually targets: an attachment-only turn still reached Anthropic as an empty user message. Apply the same stand-in after the file-context and quote merges, so a turn that already gained inline content is untouched. * Carry filenames on freshly attached files The fresh-file submission mapping copied only file_id, filepath, type, and dimensions, so the attachment-only title fallback read an undefined filename and produced nothing. Include filename, and cover it with a test that submits an empty draft with one attachment. * Address review: cover assistants v2, fresh agent attachments, editor, and title fallback - agents client: the current turn has no files during buildMessages, so read the resolved attachments from message_file_map instead. The previous guard only ever fired for persisted historical turns. - assistants chatV2: the default assistants endpoint routes here, so it needs the same stand-in body chatV1 got. - assistants title: fall back to filenames, then the response, and keep the default title rather than saving an empty one. - EditMessage: retained attachments make an empty edit submittable, matching the composer, so the overrideFiles replay path is reachable from the UI. --------- Co-authored-by: Marco Beretta <81851188+berry-13@users.noreply.github.com> --- api/app/clients/prompts/formatMessages.js | 20 ++++++- .../clients/prompts/formatMessages.spec.js | 58 +++++++++++++++++++ api/server/controllers/agents/client.js | 14 +++++ api/server/controllers/agents/request.js | 5 +- api/server/controllers/assistants/chatV1.js | 8 ++- api/server/controllers/assistants/chatV2.js | 8 ++- .../services/Endpoints/assistants/title.js | 15 ++++- client/src/components/Chat/Input/ChatForm.tsx | 9 ++- .../src/components/Chat/Input/SendButton.tsx | 8 ++- .../Chat/Messages/Content/EditMessage.tsx | 4 +- .../useChatFunctions.regenerate.spec.tsx | 50 ++++++++++++++++ client/src/hooks/Chat/useChatFunctions.ts | 15 ++++- client/src/utils/__tests__/messages.test.ts | 25 ++++++++ client/src/utils/messages.ts | 8 +++ packages/api/src/agents/client.spec.ts | 58 ++++++++++++++++++- packages/api/src/agents/client.ts | 29 ++++++++++ packages/api/src/files/context.spec.ts | 30 ++++++++++ packages/api/src/files/context.ts | 27 ++++++++- 18 files changed, 375 insertions(+), 16 deletions(-) create mode 100644 packages/api/src/files/context.spec.ts diff --git a/api/app/clients/prompts/formatMessages.js b/api/app/clients/prompts/formatMessages.js index 24af946ff7..d21aa0259f 100644 --- a/api/app/clients/prompts/formatMessages.js +++ b/api/app/clients/prompts/formatMessages.js @@ -1,3 +1,4 @@ +const { ATTACHMENT_ONLY_TEXT } = require('@librechat/api'); const { EModelEndpoint, ContentTypes } = require('librechat-data-provider'); const { AIMessage, @@ -18,12 +19,18 @@ const { * @returns {(Object)} - The formatted message. */ const formatVisionMessage = ({ message, image_urls, endpoint }) => { + // Omit an empty text part for image-only messages. Anthropic rejects empty + // text content blocks with HTTP 400, and an empty block adds nothing for + // other providers either. + const hasText = typeof message.content === 'string' && message.content.trim() !== ''; + const textPart = hasText ? [{ type: ContentTypes.TEXT, text: message.content }] : []; + if (endpoint === EModelEndpoint.anthropic) { - message.content = [...image_urls, { type: ContentTypes.TEXT, text: message.content }]; + message.content = [...image_urls, ...textPart]; return message; } - message.content = [{ type: ContentTypes.TEXT, text: message.content }, ...image_urls]; + message.content = [...textPart, ...image_urls]; return message; }; @@ -71,6 +78,15 @@ const formatMessage = ({ message, userName, assistantName, endpoint, langChain = }); } + /** + * An attachment-only turn whose files reach the model out-of-band (RAG, + * code environment) leaves nothing in the content itself, and providers + * such as Anthropic reject an empty user message outright. + */ + if (role === 'user' && content === '' && message.files?.length > 0) { + formattedMessage.content = ATTACHMENT_ONLY_TEXT; + } + if (_name) { formattedMessage.name = _name; } diff --git a/api/app/clients/prompts/formatMessages.spec.js b/api/app/clients/prompts/formatMessages.spec.js index 7cee6555c8..45b397febe 100644 --- a/api/app/clients/prompts/formatMessages.spec.js +++ b/api/app/clients/prompts/formatMessages.spec.js @@ -1,3 +1,4 @@ +const { ATTACHMENT_ONLY_TEXT } = require('@librechat/api'); const { Constants } = require('librechat-data-provider'); const { HumanMessage, AIMessage, SystemMessage } = require('@librechat/agents/langchain/messages'); const { formatMessage, formatLangChainMessages, formatFromLangChain } = require('./formatMessages'); @@ -184,6 +185,63 @@ describe('formatMessage', () => { content: 'Hello', }); }); + + it('includes the text part for vision messages that have text', () => { + const image = { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }; + const result = formatMessage({ + message: { role: 'user', text: 'Describe this', image_urls: [image] }, + endpoint: 'anthropic', + }); + expect(result.content).toEqual([image, { type: 'text', text: 'Describe this' }]); + }); + + it('omits the empty text part for image-only Anthropic messages', () => { + const image = { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }; + const result = formatMessage({ + message: { role: 'user', text: '', image_urls: [image] }, + endpoint: 'anthropic', + }); + // No empty { type: 'text', text: '' } block; Anthropic rejects those with HTTP 400. + expect(result.content).toEqual([image]); + }); + + it('omits the empty text part for image-only messages on other endpoints', () => { + const image = { type: 'image_url', image_url: { url: 'data:image/png;base64,abc' } }; + const result = formatMessage({ + message: { role: 'user', text: ' ', image_urls: [image] }, + endpoint: 'openAI', + }); + expect(result.content).toEqual([image]); + }); + + it('substitutes text for an attachment-only turn with no inline content', () => { + const result = formatMessage({ + message: { role: 'user', text: '', files: [{ file_id: 'f1', embedded: true }] }, + endpoint: 'anthropic', + }); + expect(result.content).toBe(ATTACHMENT_ONLY_TEXT); + }); + + it('keeps the user text when an attachment-only turn also has text', () => { + const result = formatMessage({ + message: { role: 'user', text: 'Summarize it', files: [{ file_id: 'f1', embedded: true }] }, + endpoint: 'anthropic', + }); + expect(result.content).toBe('Summarize it'); + }); + + it('leaves empty content alone when the turn carries no files', () => { + const result = formatMessage({ message: { role: 'user', text: '' }, endpoint: 'anthropic' }); + expect(result.content).toBe(''); + }); + + it('does not substitute text for an assistant turn', () => { + const result = formatMessage({ + message: { role: 'assistant', text: '', files: [{ file_id: 'f1' }] }, + endpoint: 'anthropic', + }); + expect(result.content).toBe(''); + }); }); describe('formatLangChainMessages', () => { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index ec94924120..a44bddf3b1 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -78,6 +78,7 @@ const { countFormattedMessageTokens, prependFileContext, prependQuotes, + applyAttachmentOnlyText, hydrateMissingIndexTokenCounts, injectSkillPrimes, collectFreshSkillPrimeNames, @@ -1368,6 +1369,19 @@ class AgentClient extends BaseClient { prependQuotes(memoryFormattedMessage, message.quotes); } + /** + * An attachment-only turn whose files reach the model out-of-band (file + * search, code environment) leaves nothing in the content itself, and + * providers such as Anthropic reject an empty user message outright. + * Applied after the context and quote merges so a turn that already + * gained inline content keeps it. The current turn is not carrying + * `files` yet (BaseClient assigns them after this returns), so the + * resolved attachments come from `message_file_map`. + */ + const turnFiles = this.message_file_map?.[message.messageId] ?? message.files; + applyAttachmentOnlyText(formattedMessage, turnFiles); + applyAttachmentOnlyText(memoryFormattedMessage, turnFiles); + memoryPayload.push(memoryFormattedMessage); const dbTokenCount = Number(orderedMessages[i].tokenCount); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 0e09f14294..2dbe305609 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -25,6 +25,7 @@ const { isSteerPreemptSupported, buildRecoveredSteerPayload, deleteAgentCheckpoint, + getAttachmentTitleText, } = require('@librechat/api'); const { disposeClient } = require('~/server/cleanup'); const { @@ -1325,7 +1326,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit if (titleEligible && titleTiming === 'immediate') { immediateTitlePromise = addTitle(req, { - text, + text: text || getAttachmentTitleText(req.body.files), conversationId, client, immediate: true, @@ -1712,7 +1713,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } } else if (shouldGenerateTitle) { addTitle(req, { - text, + text: text || getAttachmentTitleText(req.body.files), response: { ...response }, client, }) diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 631831e617..126237ffcb 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -7,6 +7,7 @@ const { checkBalance, getBalanceConfig, getModelMaxTokens, + ATTACHMENT_ONLY_TEXT, } = require('@librechat/api'); const { Time, @@ -321,9 +322,14 @@ const chatV1 = async (req, res) => { parentMessageId = previousMessages[previousMessages.length - 1].messageId; } + /** + * Threads rejects an empty message body, so an attachment-only turn sends + * a minimal note instead. The persisted message keeps its empty text. + */ + const isAttachmentOnly = !text?.trim() && files.length > 0; let userMessage = { role: 'user', - content: text, + content: isAttachmentOnly ? ATTACHMENT_ONLY_TEXT : text, metadata: { messageId: userMessageId, }, diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index 237af1b11a..354dede8fe 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -7,6 +7,7 @@ const { checkBalance, getBalanceConfig, getModelMaxTokens, + ATTACHMENT_ONLY_TEXT, } = require('@librechat/api'); const { Time, @@ -194,12 +195,17 @@ const chatV2 = async (req, res) => { parentMessageId = previousMessages[previousMessages.length - 1].messageId; } + /** + * Threads rejects an empty message body, so an attachment-only turn sends + * a minimal note instead. The persisted message keeps its empty text. + */ + const isAttachmentOnly = !text?.trim() && files.length > 0; let userMessage = { role: 'user', content: [ { type: ContentTypes.TEXT, - text, + text: isAttachmentOnly ? ATTACHMENT_ONLY_TEXT : text, }, ], metadata: { diff --git a/api/server/services/Endpoints/assistants/title.js b/api/server/services/Endpoints/assistants/title.js index b31289eb60..7c0311bfc0 100644 --- a/api/server/services/Endpoints/assistants/title.js +++ b/api/server/services/Endpoints/assistants/title.js @@ -1,4 +1,4 @@ -const { isEnabled, sanitizeTitle } = require('@librechat/api'); +const { isEnabled, sanitizeTitle, getAttachmentTitleText } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { CacheKeys } = require('librechat-data-provider'); const getLogStores = require('~/cache/getLogStores'); @@ -78,7 +78,18 @@ const addTitle = async (req, { text, responseText, conversationId }) => { ); } catch (error) { logger.error('[addTitle] Error generating title:', error); - const fallbackTitle = text.length > 40 ? text.substring(0, 37) + '...' : text; + /** + * An attachment-only turn has no text to fall back on, and saving the + * empty string would replace the conversation's default title with a + * blank sidebar entry. Use the filenames, then the response, and leave + * the default in place when neither says anything. + */ + const fallbackSource = text || getAttachmentTitleText(req?.body?.files) || responseText || ''; + if (!fallbackSource) { + return; + } + const fallbackTitle = + fallbackSource.length > 40 ? fallbackSource.substring(0, 37) + '...' : fallbackSource; await titleCache.set(key, fallbackTitle, 120000); await saveConvo( { diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index fe57678719..d75b1677d4 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -397,8 +397,14 @@ const ChatForm = memo(function ChatForm({ useQueryParams({ textAreaRef }); + /** Attachments stand in for text only on the normal send path. Answer mode + * hands the composer text straight to the paused run, which answers with + * values and cannot consume files, so an empty draft must stay unsubmittable + * there rather than enabling a button whose submit is silently dropped. */ + const submittableFileCount = answerMode.active ? 0 : files.size; + const { ref, ...registerProps } = methods.register('text', { - required: true, + required: submittableFileCount === 0, onChange: useCallback( (e: React.ChangeEvent) => methods.setValue('text', e.target.value, { shouldValidate: true }), @@ -699,6 +705,7 @@ const ChatForm = memo(function ChatForm({ ; + /** Number of attached files; attachments allow sending without text */ + fileCount?: number; }; const SubmitButton = React.memo( @@ -41,8 +43,8 @@ const SubmitButton = React.memo( const SendButton = React.memo( forwardRef((props: SendButtonProps, ref: React.ForwardedRef) => { const data = useWatch({ control: props.control }); - const content = data?.text?.trim(); - return ; + const canSubmit = isSubmittableMessage(data?.text, props.fileCount); + return ; }), ); diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx index 1fbd4d4efc..ab58961a47 100644 --- a/client/src/components/Chat/Messages/Content/EditMessage.tsx +++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx @@ -185,7 +185,9 @@ const EditMessage = ({ ); const { ref, ...registerProps } = register('text', { - required: true, + /** Retained attachments make an otherwise empty edit submittable, matching + * the composer; `ask` replays them through `overrideFiles`. */ + required: (message.files?.length ?? 0) === 0, onChange: (e) => { setValue('text', e.target.value, { shouldDirty: true, shouldValidate: true }); }, diff --git a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx index d984692c57..76a3273d01 100644 --- a/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx +++ b/client/src/hooks/Chat/__tests__/useChatFunctions.regenerate.spec.tsx @@ -57,6 +57,7 @@ jest.mock('~/store', () => ({ useGetEphemeralAgent: () => mockGetEphemeralAgent, })); jest.mock('~/utils', () => ({ + ...jest.requireActual('~/utils'), logger: { log: jest.fn(), dir: jest.fn(), @@ -292,3 +293,52 @@ describe('useChatFunctions regenerate', () => { expect(messages.at(-1)?.messageId).toBe('assistant-1_'); }); }); + +describe('useChatFunctions ask attachments', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetQueryData.mockReturnValue({}); + }); + + /** The server titles an attachment-only turn from the submitted filenames + * (getAttachmentTitleText), so the fresh-file mapping must carry them. */ + it('carries the filename on freshly attached files', () => { + const setMessages = jest.fn(); + const setSubmission = jest.fn(); + const setFiles = jest.fn(); + const files = new Map([ + [ + 'file-1', + { + file_id: 'file-1', + filepath: '/uploads/file-1', + filename: 'quarterly-report.pdf', + type: 'application/pdf', + }, + ], + ]) as unknown as Parameters[0]['files']; + + const { result } = renderHook(() => + useChatFunctions({ + isSubmitting: false, + latestMessage: null, + conversation: conversation(Constants.NEW_CONVO as string), + getMessages: () => [], + setMessages, + setSubmission, + files, + setFiles, + }), + ); + + act(() => { + result.current.ask({ text: '' }); + }); + + const submission = setSubmission.mock.calls.at(-1)?.[0] as TSubmission; + expect(submission.userMessage.files?.[0]).toMatchObject({ + file_id: 'file-1', + filename: 'quarterly-report.pdf', + }); + }); +}); diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 8bf70611e3..43725ba459 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -30,6 +30,7 @@ import { logger, requestChatFocus, hasStreamStartFailed, + isSubmittableMessage, createDualMessageContent, getRouteChatProjectId, } from '~/utils'; @@ -292,7 +293,18 @@ export default function useChatFunctions({ } = {}, ) => { text = text.trim(); - if (!!isSubmitting || text === '') { + /** + * Attached files make an otherwise empty draft submittable, e.g. replying + * to an agent that asked for a document upload. Replayed turns (regenerate, + * or save-and-submit carrying `overrideFiles`) reuse stored attachments that + * aren't in the compose `files` map, so count those too and never re-block a + * regenerate of an already-validated file-only turn. + */ + const replayFileCount = overrideFiles?.length ?? 0; + if ( + !!isSubmitting || + (!isRegenerate && !isSubmittableMessage(text, (files?.size ?? 0) + replayFileCount)) + ) { return false; } @@ -548,6 +560,7 @@ export default function useChatFunctions({ currentMsg.files = Array.from(files.values()).map((file) => ({ file_id: file.file_id, filepath: file.filepath, + filename: file.filename, type: file.type ?? '', // Ensure type is not undefined height: file.height, width: file.width, diff --git a/client/src/utils/__tests__/messages.test.ts b/client/src/utils/__tests__/messages.test.ts index 51710d2723..0a403c32ed 100644 --- a/client/src/utils/__tests__/messages.test.ts +++ b/client/src/utils/__tests__/messages.test.ts @@ -11,6 +11,7 @@ import { getHeaderPrefixForScreenReader, areMessageFieldsEqual, areMessageRowPropsEqual, + isSubmittableMessage, } from '../messages'; const translations: Record = { @@ -344,3 +345,27 @@ describe('areMessageRowPropsEqual', () => { ).toBe(false); }); }); + +describe('isSubmittableMessage', () => { + it('accepts non-whitespace text without files', () => { + expect(isSubmittableMessage('Hello')).toBe(true); + expect(isSubmittableMessage(' Hello ', 0)).toBe(true); + }); + + it('rejects an empty draft with no files', () => { + expect(isSubmittableMessage('')).toBe(false); + expect(isSubmittableMessage(' ')).toBe(false); + expect(isSubmittableMessage(undefined)).toBe(false); + expect(isSubmittableMessage(null)).toBe(false); + }); + + it('accepts an empty draft when files are attached', () => { + expect(isSubmittableMessage('', 1)).toBe(true); + expect(isSubmittableMessage(' ', 2)).toBe(true); + expect(isSubmittableMessage(undefined, 1)).toBe(true); + }); + + it('accepts text alongside files', () => { + expect(isSubmittableMessage('Translate this', 1)).toBe(true); + }); +}); diff --git a/client/src/utils/messages.ts b/client/src/utils/messages.ts index 31d5621ebc..7faa49d6be 100644 --- a/client/src/utils/messages.ts +++ b/client/src/utils/messages.ts @@ -191,6 +191,14 @@ export const getAllContentText = (message?: TMessage | null): string => { return ''; }; +/** + * Whether a draft message has enough content to submit: non-whitespace + * text, or at least one attached file. Lets users send a file without + * having to type a placeholder message alongside it. + */ +export const isSubmittableMessage = (text?: string | null, fileCount = 0): boolean => + (text ?? '').trim() !== '' || fileCount > 0; + export const hasStreamStartFailed = (message?: Pick | null): boolean => message?.metadata?.[STREAM_START_FAILED_METADATA_KEY] === true; diff --git a/packages/api/src/agents/client.spec.ts b/packages/api/src/agents/client.spec.ts index 0b77831926..c62cb09662 100644 --- a/packages/api/src/agents/client.spec.ts +++ b/packages/api/src/agents/client.spec.ts @@ -1,5 +1,12 @@ import { ContentTypes } from 'librechat-data-provider'; -import { prependFileContext, prependQuotes, type FormattedMessageWithContent } from './client'; +import type { TMessage } from 'librechat-data-provider'; +import { + prependQuotes, + prependFileContext, + applyAttachmentOnlyText, + type FormattedMessageWithContent, +} from './client'; +import { ATTACHMENT_ONLY_TEXT } from '~/files/context'; describe('prependFileContext', () => { it('prepends file context to string content', () => { @@ -113,3 +120,52 @@ describe('prependQuotes', () => { expect(message.content).toBe('Explain this.'); }); }); + +describe('applyAttachmentOnlyText', () => { + const withFiles = [{ file_id: 'f1' }] as TMessage['files']; + + it('substitutes text for an empty user turn that carries files', () => { + const message: FormattedMessageWithContent = { role: 'user', content: '' }; + + applyAttachmentOnlyText(message, withFiles); + + expect(message.content).toBe(ATTACHMENT_ONLY_TEXT); + }); + + it('leaves a user turn that already has text alone', () => { + const message: FormattedMessageWithContent = { role: 'user', content: 'Summarize it' }; + + applyAttachmentOnlyText(message, withFiles); + + expect(message.content).toBe('Summarize it'); + }); + + it('leaves content that quotes or file context already filled alone', () => { + const message: FormattedMessageWithContent = { + role: 'user', + content: [{ type: ContentTypes.TEXT, text: 'Attached file text' }], + }; + + applyAttachmentOnlyText(message, withFiles); + + expect(message.content).toEqual([{ type: ContentTypes.TEXT, text: 'Attached file text' }]); + }); + + it('ignores turns without files', () => { + const message: FormattedMessageWithContent = { role: 'user', content: '' }; + + applyAttachmentOnlyText(message, []); + expect(message.content).toBe(''); + + applyAttachmentOnlyText(message, null); + expect(message.content).toBe(''); + }); + + it('ignores non-user turns', () => { + const message: FormattedMessageWithContent = { role: 'assistant', content: '' }; + + applyAttachmentOnlyText(message, withFiles); + + expect(message.content).toBe(''); + }); +}); diff --git a/packages/api/src/agents/client.ts b/packages/api/src/agents/client.ts index df846bf8eb..6cf1dd9e04 100644 --- a/packages/api/src/agents/client.ts +++ b/packages/api/src/agents/client.ts @@ -12,6 +12,7 @@ import type { MessageContentComplex } from '@librechat/agents'; import type { Agent, TMessage } from 'librechat-data-provider'; import type { ServerRequest } from '~/types'; import { logAxiosError, mergeQuotedText, formatQuotesAsMarkdown } from '~/utils'; +import { ATTACHMENT_ONLY_TEXT } from '~/files/context'; import Tokenizer from '~/utils/tokenizer'; export const omitTitleOptions: Set = new Set([ @@ -69,9 +70,37 @@ export type FormattedMessageContentPart = { }; export type FormattedMessageWithContent = { + role?: string; content?: string | FormattedMessageContentPart[]; }; +/** + * Substitutes stand-in text for a user turn that carries attachments but has + * nothing the provider can see: file search and code environment files reach + * the model out-of-band, so the content stays empty and Anthropic rejects the + * message outright. Apply after the file-context and quote merges so a turn + * that already gained inline content is left alone. The stored `message.text` + * keeps its empty value, so the UI still renders the attachment on its own. + * + * Takes the turn's files rather than the message because the current turn does + * not carry them yet: `BaseClient` assigns `userMessage.files` only after + * `buildMessages` returns, so callers pass the resolved attachments instead. + */ +export function applyAttachmentOnlyText( + formattedMessage: FormattedMessageWithContent, + files?: TMessage['files'] | null, +): void { + if (formattedMessage.role !== 'user' || !files?.length) { + return; + } + + if (formattedMessage.content !== '') { + return; + } + + formattedMessage.content = ATTACHMENT_ONLY_TEXT; +} + export function prependFileContext( formattedMessage: FormattedMessageWithContent, fileContext?: string | null, diff --git a/packages/api/src/files/context.spec.ts b/packages/api/src/files/context.spec.ts new file mode 100644 index 0000000000..56d09ff5f4 --- /dev/null +++ b/packages/api/src/files/context.spec.ts @@ -0,0 +1,30 @@ +import type { TFile } from 'librechat-data-provider'; +import { getAttachmentTitleText } from './context'; + +const file = (filename?: string): TFile => ({ filename }) as TFile; + +describe('getAttachmentTitleText', () => { + it('returns an empty string when there are no files', () => { + expect(getAttachmentTitleText()).toBe(''); + expect(getAttachmentTitleText(null)).toBe(''); + expect(getAttachmentTitleText([])).toBe(''); + }); + + it('lists a single filename', () => { + expect(getAttachmentTitleText([file('report.pdf')])).toBe('Attached file(s): report.pdf'); + }); + + it('lists every filename', () => { + expect(getAttachmentTitleText([file('a.pdf'), file('b.csv')])).toBe( + 'Attached file(s): a.pdf, b.csv', + ); + }); + + it('skips files that carry no filename', () => { + expect(getAttachmentTitleText([file(), file('kept.txt')])).toBe('Attached file(s): kept.txt'); + }); + + it('returns an empty string when no file has a filename', () => { + expect(getAttachmentTitleText([file(), file()])).toBe(''); + }); +}); diff --git a/packages/api/src/files/context.ts b/packages/api/src/files/context.ts index 36209f34c1..b16b498b35 100644 --- a/packages/api/src/files/context.ts +++ b/packages/api/src/files/context.ts @@ -1,9 +1,34 @@ import { logger } from '@librechat/data-schemas'; import { FileSources, mergeFileConfig } from 'librechat-data-provider'; import type { IMongoFile } from '@librechat/data-schemas'; +import type { TFile } from 'librechat-data-provider'; +import type { TokenCountFn } from '~/utils/text'; import type { ServerRequest } from '~/types'; import { processTextWithTokenLimit } from '~/utils/text'; -import type { TokenCountFn } from '~/utils/text'; + +/** + * Stand-in text for a user turn that carries attachments but no typed message. + * Anthropic and the Assistants API both reject empty user content, and files + * that reach the model out-of-band (RAG, code environment) leave nothing else + * in the turn, so the payload needs this minimal note. The stored message keeps + * its empty text so the UI still renders the attachment on its own. + */ +export const ATTACHMENT_ONLY_TEXT = 'Please refer to the attached file(s).'; + +/** + * Title-generation input for a turn the user sent without typing anything. + * Immediate title timing runs before any response exists, so the attachment + * filenames are the only conversation-specific signal available; without them + * the title model is prompted with an empty string and invents a topic. + */ +export function getAttachmentTitleText(files?: TFile[] | null): string { + if (!files?.length) { + return ''; + } + + const filenames = files.map((file) => file.filename).filter(Boolean); + return filenames.length > 0 ? `Attached file(s): ${filenames.join(', ')}` : ''; +} /** * Extracts text context from attachments and returns formatted text.