LibreChat/client/src/hooks/Chat/useChatFunctions.ts
Danny Avila c0ebb434a6
🎨 feat: OpenAI Image Tools (GPT-Image-1) (#7079)
* wip: OpenAI Image Generation Tool with customizable options

* WIP: First pass OpenAI Image Generation Tool and integrate into existing tools

* 🔀 fix: Comment out unused validation for image generation tool parameters

* 🔀 refactor: Update primeResources function parameters for better destructuring

* feat: Add image_edit resource to EToolResources and update AgentToolResources interface

* feat: Enhance file retrieval with tool resource filtering for image editing

* refactor: add OpenAI Image Tools for generation and editing, refactor related components, pass current request image attachments as tool resources for editing

* refactor: Remove commented-out code and clean up API key retrieval in createOpenAIImageTools function

* fix: show message attachments in shared links

* fix: Correct parent message retrieval logic for regenerated messages in useChatFunctions

* fix: Update primeResources to utilize requestFileSet for image file processing

* refactor: Improve description for image generation tool and clarify usage conditions, only provide edit tool if there are images available to edit

* chore: Update OpenAI Image Tools icon to use local asset

* refactor: Update image generation tool description and logic to prioritize editing tool when files are uploaded

* refactor: Enhance image tool descriptions to clarify usage conditions and note potential unavailability of uploaded images

* refactor: Update useAttachmentHandler to accept queryClient to update query cache with newly created file

* refactor: Add customizable descriptions and prompts for OpenAI image generation and editing tools

* chore: Update comments to use JSDoc style for better clarity and consistency

* refactor: Rename config variable to clientConfig for clarity and update signal handling in image generation

* refactor: Update axios request configuration to include derived signal and baseURL for improved request handling

* refactor: Update baseURL environment variable for OpenAI image generation tool configuration

* refactor: Enhance axios request configuration with conditional headers and improved clientConfig setup

* chore: Update comments for clarity and remove unnecessary lines in OpenAI image tools

* refactor: Update description for image generation without files to clarify user instructions

* refactor: Simplify target parent message logic for regeneration and resubmission cases

* chore: Remove backticks from error messages in image generation and editing functions

* refactor: Rename toolResources to toolResourceSet for clarity in file retrieval functions

* chore: Remove redundant comments and clean up TODOs in OpenAI image tools

* refactor: Rename fileStrategy to appFileStrategy for clarity and improve error handling in image processing

* chore: Update react-resizable-panels to version 2.1.8 in package.json and package-lock.json

* chore: Ensure required validation for logs and Code of Conduct agreement in bug report template

* fix: Update ArtifactPreview to use startupConfig and currentCode from memoized props to prevent unnecessary re-renders

* fix: improve robustness of `save & submit` when used from a user-message with existing attachments

* fix: add null check for artifact index in CodeEditor to prevent errors, trigger re-render on artifact ID change

* fix: standardize default values for artifact properties in Artifact component, avoiding prematurely setting an "empty/default" artifact

* fix: reset current artifact ID before setting a new one in ArtifactButton to ensure correct state management

* chore: rename `setArtifactId` variable to `setCurrentArtifactId`  for consistency

* chore: update type annotations in File and S3 CRUD functions for consistency

* refactor: improve image handling in OpenAI tools by using image_id references and enhance tool context for image editing

* fix: update image_ids schema in image_edit_oai to enforce presence and provide clear guidelines for usage

* fix: enhance file fetching logic to ensure user-specific and dimension-validated results

* chore: add details on image generation and editing capabilities with various models
2025-04-26 04:30:58 -04:00

347 lines
11 KiB
TypeScript

import { v4 } from 'uuid';
import { useQueryClient } from '@tanstack/react-query';
import {
Constants,
QueryKeys,
ContentTypes,
EModelEndpoint,
parseCompactConvo,
isAssistantsEndpoint,
} from 'librechat-data-provider';
import { useSetRecoilState, useResetRecoilState, useRecoilValue } from 'recoil';
import type {
TMessage,
TSubmission,
TConversation,
TEndpointOption,
TEndpointsConfig,
EndpointSchemaKey,
} from 'librechat-data-provider';
import type { SetterOrUpdater } from 'recoil';
import type { TAskFunction, ExtendedFile } from '~/common';
import useSetFilesToDelete from '~/hooks/Files/useSetFilesToDelete';
import useGetSender from '~/hooks/Conversations/useGetSender';
import store, { useGetEphemeralAgent } from '~/store';
import { getArtifactsMode } from '~/utils/artifacts';
import { getEndpointField, logger } from '~/utils';
import useUserKey from '~/hooks/Input/useUserKey';
const logChatRequest = (request: Record<string, unknown>) => {
logger.log('=====================================\nAsk function called with:');
logger.dir(request);
logger.log('=====================================');
};
const usesContentStream = (endpoint: EModelEndpoint | undefined, endpointType?: string) => {
if (endpointType === EModelEndpoint.custom) {
return true;
}
if (endpoint === EModelEndpoint.openAI || endpoint === EModelEndpoint.azureOpenAI) {
return true;
}
};
export default function useChatFunctions({
index = 0,
files,
setFiles,
getMessages,
setMessages,
isSubmitting,
conversation,
latestMessage,
setSubmission,
setLatestMessage,
}: {
index?: number;
isSubmitting: boolean;
paramId?: string | undefined;
conversation: TConversation | null;
latestMessage: TMessage | null;
getMessages: () => TMessage[] | undefined;
setMessages: (messages: TMessage[]) => void;
files?: Map<string, ExtendedFile>;
setFiles?: SetterOrUpdater<Map<string, ExtendedFile>>;
setSubmission: SetterOrUpdater<TSubmission | null>;
setLatestMessage?: SetterOrUpdater<TMessage | null>;
}) {
const getEphemeralAgent = useGetEphemeralAgent();
const codeArtifacts = useRecoilValue(store.codeArtifacts);
const includeShadcnui = useRecoilValue(store.includeShadcnui);
const customPromptMode = useRecoilValue(store.customPromptMode);
const resetLatestMultiMessage = useResetRecoilState(store.latestMessageFamily(index + 1));
const setShowStopButton = useSetRecoilState(store.showStopButtonByIndex(index));
const setFilesToDelete = useSetFilesToDelete();
const getSender = useGetSender();
const isTemporary = useRecoilValue(store.isTemporary);
const queryClient = useQueryClient();
const { getExpiry } = useUserKey(conversation?.endpoint ?? '');
const ask: TAskFunction = (
{
text,
overrideConvoId,
overrideUserMessageId,
parentMessageId = null,
conversationId = null,
messageId = null,
},
{
editedText = null,
editedMessageId = null,
isResubmission = false,
isRegenerate = false,
isContinued = false,
isEdited = false,
overrideMessages,
overrideFiles,
} = {},
) => {
setShowStopButton(false);
resetLatestMultiMessage();
if (!!isSubmitting || text === '') {
return;
}
const endpoint = conversation?.endpoint;
if (endpoint === null) {
console.error('No endpoint available');
return;
}
conversationId = conversationId ?? conversation?.conversationId ?? null;
if (conversationId == 'search') {
console.error('cannot send any message under search view!');
return;
}
if (isContinued && !latestMessage) {
console.error('cannot continue AI message without latestMessage!');
return;
}
const ephemeralAgent = getEphemeralAgent(conversationId ?? Constants.NEW_CONVO);
const isEditOrContinue = isEdited || isContinued;
let currentMessages: TMessage[] | null = overrideMessages ?? getMessages() ?? [];
// construct the query message
// this is not a real messageId, it is used as placeholder before real messageId returned
text = text.trim();
const intermediateId = overrideUserMessageId ?? v4();
parentMessageId = parentMessageId ?? latestMessage?.messageId ?? Constants.NO_PARENT;
logChatRequest({
index,
conversation,
latestMessage,
conversationId,
intermediateId,
parentMessageId,
currentMessages,
});
if (conversationId == Constants.NEW_CONVO) {
parentMessageId = Constants.NO_PARENT;
currentMessages = [];
conversationId = null;
}
const targetParentMessageId = isRegenerate ? messageId : latestMessage?.parentMessageId;
/**
* If the user regenerated or resubmitted the message, the current parent is technically
* the latest user message, which is passed into `ask`; otherwise, we can rely on the
* latestMessage to find the parent.
*/
const targetParentMessage = currentMessages.find(
(msg) => msg.messageId === targetParentMessageId,
);
let thread_id = targetParentMessage?.thread_id ?? latestMessage?.thread_id;
if (thread_id == null) {
thread_id = currentMessages.find((message) => message.thread_id)?.thread_id;
}
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
/** This becomes part of the `endpointOption` */
const convo = parseCompactConvo({
endpoint: endpoint as EndpointSchemaKey,
endpointType: endpointType as EndpointSchemaKey,
conversation: conversation ?? {},
});
const { modelDisplayLabel } = endpointsConfig?.[endpoint ?? ''] ?? {};
const endpointOption = Object.assign(
{
endpoint,
endpointType,
overrideConvoId,
overrideUserMessageId,
artifacts:
endpoint !== EModelEndpoint.agents
? getArtifactsMode({ codeArtifacts, includeShadcnui, customPromptMode })
: undefined,
},
convo,
) as TEndpointOption;
if (endpoint !== EModelEndpoint.agents) {
endpointOption.key = getExpiry();
endpointOption.thread_id = thread_id;
endpointOption.modelDisplayLabel = modelDisplayLabel;
} else {
endpointOption.key = new Date(Date.now() + 60 * 60 * 1000).toISOString();
}
const responseSender = getSender({ model: conversation?.model, ...endpointOption });
const currentMsg: TMessage = {
text,
sender: 'User',
clientTimestamp: new Date().toLocaleString('sv').replace(' ', 'T'),
isCreatedByUser: true,
parentMessageId,
conversationId,
messageId: isContinued && messageId != null && messageId ? messageId : intermediateId,
thread_id,
error: false,
};
const submissionFiles = overrideFiles ?? targetParentMessage?.files;
const reuseFiles =
(isRegenerate || (overrideFiles != null && overrideFiles.length)) &&
submissionFiles &&
submissionFiles.length > 0;
if (setFiles && reuseFiles === true) {
currentMsg.files = [...submissionFiles];
setFiles(new Map());
setFilesToDelete({});
} else if (setFiles && files && files.size > 0) {
currentMsg.files = Array.from(files.values()).map((file) => ({
file_id: file.file_id,
filepath: file.filepath,
type: file.type ?? '', // Ensure type is not undefined
height: file.height,
width: file.width,
}));
setFiles(new Map());
setFilesToDelete({});
}
const generation = editedText ?? latestMessage?.text ?? '';
const responseText = isEditOrContinue ? generation : '';
const responseMessageId = editedMessageId ?? latestMessage?.messageId ?? null;
const initialResponse: TMessage = {
sender: responseSender,
text: responseText,
endpoint: endpoint ?? '',
parentMessageId: isRegenerate ? messageId : intermediateId,
messageId: responseMessageId ?? `${isRegenerate ? messageId : intermediateId}_`,
thread_id,
conversationId,
unfinished: false,
isCreatedByUser: false,
iconURL: convo?.iconURL,
model: convo?.model,
error: false,
};
if (isAssistantsEndpoint(endpoint)) {
initialResponse.model = conversation?.assistant_id ?? '';
initialResponse.text = '';
initialResponse.content = [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: responseText,
},
},
];
} else if (endpoint === EModelEndpoint.agents) {
initialResponse.model = conversation?.agent_id ?? '';
initialResponse.text = '';
initialResponse.content = [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: responseText,
},
},
];
setShowStopButton(true);
} else if (usesContentStream(endpoint, endpointType)) {
initialResponse.text = '';
initialResponse.content = [
{
type: ContentTypes.TEXT,
[ContentTypes.TEXT]: {
value: responseText,
},
},
];
setShowStopButton(true);
} else {
setShowStopButton(true);
}
if (isContinued) {
currentMessages = currentMessages.filter((msg) => msg.messageId !== responseMessageId);
}
logger.log('message_state', initialResponse);
const submission: TSubmission = {
conversation: {
...conversation,
conversationId,
},
endpointOption,
userMessage: {
...currentMsg,
generation,
responseMessageId,
overrideParentMessageId: isRegenerate ? messageId : null,
},
messages: currentMessages,
isEdited: isEditOrContinue,
isContinued,
isRegenerate,
isResubmission,
initialResponse,
isTemporary,
ephemeralAgent,
};
if (isRegenerate) {
setMessages([...submission.messages, initialResponse]);
} else {
setMessages([...submission.messages, currentMsg, initialResponse]);
}
if (index === 0 && setLatestMessage) {
setLatestMessage(initialResponse);
}
setSubmission(submission);
logger.dir('message_stream', submission, { depth: null });
};
const regenerate = ({ parentMessageId }) => {
const messages = getMessages();
const parentMessage = messages?.find((element) => element.messageId == parentMessageId);
if (parentMessage && parentMessage.isCreatedByUser) {
ask({ ...parentMessage }, { isRegenerate: true });
} else {
console.error(
'Failed to regenerate the message: parentMessage not found or not created by user.',
);
}
};
return {
ask,
regenerate,
};
}