mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 12:44:28 +00:00
🤐 feat: Allow Promptless Sends When Files Are Attached (#13717)
* ✨ 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>
This commit is contained in:
parent
e1ac7d2bda
commit
a2ad0aa0c8
18 changed files with 375 additions and 16 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue