From c0ebb434a67c242a62e8c1bd37d5f854e2bd558d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 26 Apr 2025 04:30:58 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20feat:=20OpenAI=20Image=20Tools?= =?UTF-8?q?=20(GPT-Image-1)=20(#7079)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .env.example | 8 + .github/ISSUE_TEMPLATE/BUG-REPORT.yml | 2 + README.md | 5 + api/app/clients/tools/index.js | 2 + api/app/clients/tools/manifest.json | 14 + .../tools/structured/OpenAIImageTools.js | 518 ++++++++++++++++++ api/app/clients/tools/util/handleTools.js | 39 +- api/models/File.js | 38 +- api/models/Share.js | 9 + api/server/controllers/agents/callbacks.js | 8 +- .../services/Endpoints/agents/initialize.js | 59 +- api/server/services/Files/Local/crud.js | 18 + api/server/services/Files/S3/crud.js | 4 +- api/server/services/ToolService.js | 15 +- api/typedefs.js | 22 +- client/package.json | 2 +- client/public/assets/image_gen_oai.png | Bin 0 -> 38419 bytes client/src/common/types.ts | 5 +- client/src/components/Artifacts/Artifact.tsx | 13 +- .../components/Artifacts/ArtifactButton.tsx | 12 +- .../Artifacts/ArtifactCodeEditor.tsx | 3 + .../components/Artifacts/ArtifactPreview.tsx | 22 +- .../src/components/Artifacts/ArtifactTabs.tsx | 16 +- .../Chat/Messages/Content/EditMessage.tsx | 1 + .../Chat/Messages/Content/SearchContent.tsx | 19 +- client/src/hooks/Chat/useChatFunctions.ts | 24 +- client/src/hooks/SSE/useAttachmentHandler.ts | 10 +- client/src/hooks/SSE/useEventHandlers.ts | 2 +- package-lock.json | 21 +- .../data-provider/src/types/assistants.ts | 34 +- 30 files changed, 841 insertions(+), 104 deletions(-) create mode 100644 api/app/clients/tools/structured/OpenAIImageTools.js create mode 100644 client/public/assets/image_gen_oai.png diff --git a/.env.example b/.env.example index 6e552c24a..8ae71409c 100644 --- a/.env.example +++ b/.env.example @@ -231,6 +231,14 @@ AZURE_AI_SEARCH_SEARCH_OPTION_QUERY_TYPE= AZURE_AI_SEARCH_SEARCH_OPTION_TOP= AZURE_AI_SEARCH_SEARCH_OPTION_SELECT= +# OpenAI Image Tools Customization +#---------------- +# IMAGE_GEN_OAI_DESCRIPTION_WITH_FILES=Custom description for image generation tool when files are present +# IMAGE_GEN_OAI_DESCRIPTION_NO_FILES=Custom description for image generation tool when no files are present +# IMAGE_EDIT_OAI_DESCRIPTION=Custom description for image editing tool +# IMAGE_GEN_OAI_PROMPT_DESCRIPTION=Custom prompt description for image generation tool +# IMAGE_EDIT_OAI_PROMPT_DESCRIPTION=Custom prompt description for image editing tool + # DALL·E #---------------- # DALLE_API_KEY= diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index 3a3b828ee..610396959 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -79,6 +79,8 @@ body: For UI-related issues, browser console logs can be very helpful. You can provide these as screenshots or paste the text here. render: shell + validations: + required: true - type: textarea id: screenshots attributes: diff --git a/README.md b/README.md index 3e02c2cc0..6e0c92221 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,11 @@ - 🪄 **Generative UI with Code Artifacts**: - [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat +- 🎨 **Image Generation & Editing** + - Text-to-image and image-to-image with [GPT-Image-1](https://www.librechat.ai/docs/features/image_gen#1--openai-image-tools-recommended) + - Text-to-image with [DALL-E (3/2)](https://www.librechat.ai/docs/features/image_gen#2--dalle-legacy), [Stable Diffusion](https://www.librechat.ai/docs/features/image_gen#3--stable-diffusion-local), [Flux](https://www.librechat.ai/docs/features/image_gen#4--flux), or any [MCP server](https://www.librechat.ai/docs/features/image_gen#5--model-context-protocol-mcp) + - Produce stunning visuals from prompts or refine existing images with a single instruction + - 💾 **Presets & Context Management**: - Create, Save, & Share Custom Presets - Switch between AI Endpoints and Presets mid-chat diff --git a/api/app/clients/tools/index.js b/api/app/clients/tools/index.js index df436fb08..87b1884e8 100644 --- a/api/app/clients/tools/index.js +++ b/api/app/clients/tools/index.js @@ -10,6 +10,7 @@ const StructuredACS = require('./structured/AzureAISearch'); const StructuredSD = require('./structured/StableDiffusion'); const GoogleSearchAPI = require('./structured/GoogleSearch'); const TraversaalSearch = require('./structured/TraversaalSearch'); +const createOpenAIImageTools = require('./structured/OpenAIImageTools'); const TavilySearchResults = require('./structured/TavilySearchResults'); /** @type {Record} */ @@ -40,4 +41,5 @@ module.exports = { StructuredWolfram, createYouTubeTools, TavilySearchResults, + createOpenAIImageTools, }; diff --git a/api/app/clients/tools/manifest.json b/api/app/clients/tools/manifest.json index 43be7a4e6..55c1b1c51 100644 --- a/api/app/clients/tools/manifest.json +++ b/api/app/clients/tools/manifest.json @@ -44,6 +44,20 @@ } ] }, + { + "name": "OpenAI Image Tools", + "pluginKey": "image_gen_oai", + "toolkit": true, + "description": "Image Generation and Editing using OpenAI's latest state-of-the-art models", + "icon": "/assets/image_gen_oai.png", + "authConfig": [ + { + "authField": "IMAGE_GEN_OAI_API_KEY", + "label": "OpenAI Image Tools API Key", + "description": "Your OpenAI API Key for Image Generation and Editing" + } + ] + }, { "name": "Wolfram", "pluginKey": "wolfram", diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js new file mode 100644 index 000000000..85941a779 --- /dev/null +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -0,0 +1,518 @@ +const { z } = require('zod'); +const axios = require('axios'); +const { v4 } = require('uuid'); +const OpenAI = require('openai'); +const FormData = require('form-data'); +const { tool } = require('@langchain/core/tools'); +const { HttpsProxyAgent } = require('https-proxy-agent'); +const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { logAxiosError, extractBaseURL } = require('~/utils'); +const { getFiles } = require('~/models/File'); +const { logger } = require('~/config'); + +/** Default descriptions for image generation tool */ +const DEFAULT_IMAGE_GEN_DESCRIPTION = ` +Generates high-quality, original images based solely on text, not using any uploaded reference images. + +When to use \`image_gen_oai\`: +- To create entirely new images from detailed text descriptions that do NOT reference any image files. + +When NOT to use \`image_gen_oai\`: +- If the user has uploaded any images and requests modifications, enhancements, or remixing based on those uploads → use \`image_edit_oai\` instead. + +Generated image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`. +`.trim(); + +/** Default description for image editing tool */ +const DEFAULT_IMAGE_EDIT_DESCRIPTION = + `Generates high-quality, original images based on text and one or more uploaded/referenced images. + +When to use \`image_edit_oai\`: +- The user wants to modify, extend, or remix one **or more** uploaded images, either: + - Previously generated, or in the current request (both to be included in the \`image_ids\` array). +- Always when the user refers to uploaded images for editing, enhancement, remixing, style transfer, or combining elements. +- Any current or existing images are to be used as visual guides. +- If there are any files in the current request, they are more likely than not expected as references for image edit requests. + +When NOT to use \`image_edit_oai\`: +- Brand-new generations that do not rely on an existing image → use \`image_gen_oai\` instead. + +Both generated and referenced image IDs will be returned in the response, so you can refer to them in future requests made to \`image_edit_oai\`. +`.trim(); + +/** Default prompt descriptions */ +const DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION = `Describe the image you want in detail. + Be highly specific—break your idea into layers: + (1) main concept and subject, + (2) composition and position, + (3) lighting and mood, + (4) style, medium, or camera details, + (5) important features (age, expression, clothing, etc.), + (6) background. + Use positive, descriptive language and specify what should be included, not what to avoid. + List number and characteristics of people/objects, and mention style/technical requirements (e.g., "DSLR photo, 85mm lens, golden hour"). + Do not reference any uploaded images—use for new image creation from text only.`; + +const DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION = `Describe the changes, enhancements, or new ideas to apply to the uploaded image(s). + Be highly specific—break your request into layers: + (1) main concept or transformation, + (2) specific edits/replacements or composition guidance, + (3) desired style, mood, or technique, + (4) features/items to keep, change, or add (such as objects, people, clothing, lighting, etc.). + Use positive, descriptive language and clarify what should be included or changed, not what to avoid. + Always base this prompt on the most recently uploaded reference images.`; + +const displayMessage = + 'The tool displayed an image. All generated images are already plainly visible, so don\'t repeat the descriptions in detail. Do not list download links as they are available in the UI already. The user may download the images by clicking on them, but do not mention anything about downloading to the user.'; + +/** + * Replaces unwanted characters from the input string + * @param {string} inputString - The input string to process + * @returns {string} - The processed string + */ +function replaceUnwantedChars(inputString) { + return inputString + .replace(/\r\n|\r|\n/g, ' ') + .replace(/"/g, '') + .trim(); +} + +function returnValue(value) { + if (typeof value === 'string') { + return [value, {}]; + } else if (typeof value === 'object') { + if (Array.isArray(value)) { + return value; + } + return [displayMessage, value]; + } + return value; +} + +const getImageGenDescription = () => { + return process.env.IMAGE_GEN_OAI_DESCRIPTION || DEFAULT_IMAGE_GEN_DESCRIPTION; +}; + +const getImageEditDescription = () => { + return process.env.IMAGE_EDIT_OAI_DESCRIPTION || DEFAULT_IMAGE_EDIT_DESCRIPTION; +}; + +const getImageGenPromptDescription = () => { + return process.env.IMAGE_GEN_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_GEN_PROMPT_DESCRIPTION; +}; + +const getImageEditPromptDescription = () => { + return process.env.IMAGE_EDIT_OAI_PROMPT_DESCRIPTION || DEFAULT_IMAGE_EDIT_PROMPT_DESCRIPTION; +}; + +/** + * Creates OpenAI Image tools (generation and editing) + * @param {Object} fields - Configuration fields + * @param {ServerRequest} fields.req - Whether the tool is being used in an agent context + * @param {boolean} fields.isAgent - Whether the tool is being used in an agent context + * @param {string} fields.IMAGE_GEN_OAI_API_KEY - The OpenAI API key + * @param {boolean} [fields.override] - Whether to override the API key check, necessary for app initialization + * @param {MongoFile[]} [fields.imageFiles] - The images to be used for editing + * @returns {Array} - Array of image tools + */ +function createOpenAIImageTools(fields = {}) { + /** @type {boolean} Used to initialize the Tool without necessary variables. */ + const override = fields.override ?? false; + /** @type {boolean} */ + if (!override && !fields.isAgent) { + throw new Error('This tool is only available for agents.'); + } + const { req } = fields; + const imageOutputType = req?.app.locals.imageOutputType || EImageOutputType.PNG; + const appFileStrategy = req?.app.locals.fileStrategy; + + const getApiKey = () => { + const apiKey = process.env.IMAGE_GEN_OAI_API_KEY ?? ''; + if (!apiKey && !override) { + throw new Error('Missing IMAGE_GEN_OAI_API_KEY environment variable.'); + } + return apiKey; + }; + + let apiKey = fields.IMAGE_GEN_OAI_API_KEY ?? getApiKey(); + const closureConfig = { apiKey }; + + let baseURL = 'https://api.openai.com/v1/'; + if (!override && process.env.IMAGE_GEN_OAI_BASEURL) { + baseURL = extractBaseURL(process.env.IMAGE_GEN_OAI_BASEURL); + closureConfig.baseURL = baseURL; + } + + // Note: Azure may not yet support the latest image generation models + if ( + !override && + process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && + process.env.IMAGE_GEN_OAI_BASEURL + ) { + baseURL = process.env.IMAGE_GEN_OAI_BASEURL; + closureConfig.baseURL = baseURL; + closureConfig.defaultQuery = { 'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION }; + closureConfig.defaultHeaders = { + 'api-key': process.env.IMAGE_GEN_OAI_API_KEY, + 'Content-Type': 'application/json', + }; + closureConfig.apiKey = process.env.IMAGE_GEN_OAI_API_KEY; + } + + const imageFiles = fields.imageFiles ?? []; + + /** + * Image Generation Tool + */ + const imageGenTool = tool( + async ( + { + prompt, + background = 'auto', + n = 1, + output_compression = 100, + quality = 'auto', + size = 'auto', + }, + runnableConfig, + ) => { + if (!prompt) { + throw new Error('Missing required field: prompt'); + } + const clientConfig = { ...closureConfig }; + if (process.env.PROXY) { + clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY); + } + + /** @type {OpenAI} */ + const openai = new OpenAI(clientConfig); + let output_format = imageOutputType; + if ( + background === 'transparent' && + output_format !== EImageOutputType.PNG && + output_format !== EImageOutputType.WEBP + ) { + logger.warn( + '[ImageGenOAI] Transparent background requires PNG or WebP format, defaulting to PNG', + ); + output_format = EImageOutputType.PNG; + } + + let resp; + try { + const derivedSignal = runnableConfig?.signal + ? AbortSignal.any([runnableConfig.signal]) + : undefined; + resp = await openai.images.generate( + { + model: 'gpt-image-1', + prompt: replaceUnwantedChars(prompt), + n: Math.min(Math.max(1, n), 10), + background, + output_format, + output_compression: + output_format === EImageOutputType.WEBP || output_format === EImageOutputType.JPEG + ? output_compression + : undefined, + quality, + size, + }, + { + signal: derivedSignal, + }, + ); + } catch (error) { + const message = '[image_gen_oai] Problem generating the image:'; + logAxiosError({ error, message }); + return returnValue(`Something went wrong when trying to generate the image. The OpenAI API may be unavailable: +Error Message: ${error.message}`); + } + + if (!resp) { + return returnValue( + 'Something went wrong when trying to generate the image. The OpenAI API may be unavailable', + ); + } + + // For gpt-image-1, the response contains base64-encoded images + // TODO: handle cost in `resp.usage` + const base64Image = resp.data[0].b64_json; + + if (!base64Image) { + return returnValue( + 'No image data returned from OpenAI API. There may be a problem with the API or your configuration.', + ); + } + + const content = [ + { + type: ContentTypes.IMAGE_URL, + image_url: { + url: `data:image/${output_format};base64,${base64Image}`, + }, + }, + ]; + + const file_ids = [v4()]; + const response = [ + { + type: ContentTypes.TEXT, + text: displayMessage + `\n\ngenerated_image_id: "${file_ids[0]}"`, + }, + ]; + return [response, { content, file_ids }]; + }, + { + name: 'image_gen_oai', + description: getImageGenDescription(), + schema: z.object({ + prompt: z.string().max(32000).describe(getImageGenPromptDescription()), + background: z + .enum(['transparent', 'opaque', 'auto']) + .optional() + .describe( + 'Sets transparency for the background. Must be one of transparent, opaque or auto (default). When transparent, the output format should be png or webp.', + ), + /* + n: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe('The number of images to generate. Must be between 1 and 10.'), + output_compression: z + .number() + .int() + .min(0) + .max(100) + .optional() + .describe('The compression level (0-100%) for webp or jpeg formats. Defaults to 100.'), + */ + quality: z + .enum(['auto', 'high', 'medium', 'low']) + .optional() + .describe('The quality of the image. One of auto (default), high, medium, or low.'), + size: z + .enum(['auto', '1024x1024', '1536x1024', '1024x1536']) + .optional() + .describe( + 'The size of the generated image. One of 1024x1024, 1536x1024 (landscape), 1024x1536 (portrait), or auto (default).', + ), + }), + responseFormat: 'content_and_artifact', + }, + ); + + /** + * Image Editing Tool + */ + const imageEditTool = tool( + async ({ prompt, image_ids, quality = 'auto', size = 'auto' }, runnableConfig) => { + if (!prompt) { + throw new Error('Missing required field: prompt'); + } + + const clientConfig = { ...closureConfig }; + if (process.env.PROXY) { + clientConfig.httpAgent = new HttpsProxyAgent(process.env.PROXY); + } + + const formData = new FormData(); + formData.append('model', 'gpt-image-1'); + formData.append('prompt', replaceUnwantedChars(prompt)); + // TODO: `mask` support + // TODO: more than 1 image support + // formData.append('n', n.toString()); + formData.append('quality', quality); + formData.append('size', size); + + /** @type {Record>} */ + const streamMethods = {}; + + const requestFilesMap = Object.fromEntries(imageFiles.map((f) => [f.file_id, { ...f }])); + + const orderedFiles = new Array(image_ids.length); + const idsToFetch = []; + const indexOfMissing = Object.create(null); + + for (let i = 0; i < image_ids.length; i++) { + const id = image_ids[i]; + const file = requestFilesMap[id]; + + if (file) { + orderedFiles[i] = file; + } else { + idsToFetch.push(id); + indexOfMissing[id] = i; + } + } + + if (idsToFetch.length) { + const fetchedFiles = await getFiles( + { + user: req.user.id, + file_id: { $in: idsToFetch }, + height: { $exists: true }, + width: { $exists: true }, + }, + {}, + {}, + ); + + for (const file of fetchedFiles) { + requestFilesMap[file.file_id] = file; + orderedFiles[indexOfMissing[file.file_id]] = file; + } + } + for (const imageFile of orderedFiles) { + if (!imageFile) { + continue; + } + /** @type {NodeStream} */ + let stream; + /** @type {NodeStreamDownloader} */ + let getDownloadStream; + const source = imageFile.source || appFileStrategy; + if (!source) { + throw new Error('No source found for image file'); + } + if (streamMethods[source]) { + getDownloadStream = streamMethods[source]; + } else { + ({ getDownloadStream } = getStrategyFunctions(source)); + streamMethods[source] = getDownloadStream; + } + if (!getDownloadStream) { + throw new Error(`No download stream method found for source: ${source}`); + } + stream = await getDownloadStream(req, imageFile.filepath); + if (!stream) { + throw new Error('Failed to get download stream for image file'); + } + formData.append('image[]', stream, { + filename: imageFile.filename, + contentType: imageFile.type, + }); + } + + /** @type {import('axios').RawAxiosHeaders} */ + let headers = { + ...formData.getHeaders(), + }; + + if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) { + headers['api-key'] = apiKey; + } else { + headers['Authorization'] = `Bearer ${apiKey}`; + } + + try { + const derivedSignal = runnableConfig?.signal + ? AbortSignal.any([runnableConfig.signal]) + : undefined; + + /** @type {import('axios').AxiosRequestConfig} */ + const axiosConfig = { + headers, + ...clientConfig, + signal: derivedSignal, + baseURL, + }; + + if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) { + axiosConfig.params = { + 'api-version': process.env.IMAGE_GEN_OAI_AZURE_API_VERSION, + ...axiosConfig.params, + }; + } + const response = await axios.post('/images/edits', formData, axiosConfig); + + if (!response.data || !response.data.data || !response.data.data.length) { + return returnValue( + 'No image data returned from OpenAI API. There may be a problem with the API or your configuration.', + ); + } + + const base64Image = response.data.data[0].b64_json; + if (!base64Image) { + return returnValue( + 'No image data returned from OpenAI API. There may be a problem with the API or your configuration.', + ); + } + + const content = [ + { + type: ContentTypes.IMAGE_URL, + image_url: { + url: `data:image/${imageOutputType};base64,${base64Image}`, + }, + }, + ]; + + const file_ids = [v4()]; + const textResponse = [ + { + type: ContentTypes.TEXT, + text: + displayMessage + + `\n\ngenerated_image_id: "${file_ids[0]}"\nreferenced_image_ids: ["${image_ids.join('", "')}"]`, + }, + ]; + return [textResponse, { content, file_ids }]; + } catch (error) { + const message = '[image_edit_oai] Problem editing the image:'; + logAxiosError({ error, message }); + return returnValue(`Something went wrong when trying to edit the image. The OpenAI API may be unavailable: +Error Message: ${error.message || 'Unknown error'}`); + } + }, + { + name: 'image_edit_oai', + description: getImageEditDescription(), + schema: z.object({ + image_ids: z + .array(z.string()) + .min(1) + .describe( + ` +IDs (image ID strings) of previously generated or uploaded images that should guide the edit. + +Guidelines: +- If the user's request depends on any prior image(s), copy their image IDs into the \`image_ids\` array (in the same order the user refers to them). +- Never invent or hallucinate IDs; only use IDs that are still visible in the conversation context. +- If no earlier image is relevant, omit the field entirely. +`.trim(), + ), + prompt: z.string().max(32000).describe(getImageEditPromptDescription()), + /* + n: z + .number() + .int() + .min(1) + .max(10) + .optional() + .describe('The number of images to generate. Must be between 1 and 10. Defaults to 1.'), + */ + quality: z + .enum(['auto', 'high', 'medium', 'low']) + .optional() + .describe( + 'The quality of the image. One of auto (default), high, medium, or low. High/medium/low only supported for gpt-image-1.', + ), + size: z + .enum(['auto', '1024x1024', '1536x1024', '1024x1536', '256x256', '512x512']) + .optional() + .describe( + 'The size of the generated images. For gpt-image-1: auto (default), 1024x1024, 1536x1024, 1024x1536. For dall-e-2: 256x256, 512x512, 1024x1024.', + ), + }), + responseFormat: 'content_and_artifact', + }, + ); + + return [imageGenTool, imageEditTool]; +} + +module.exports = createOpenAIImageTools; diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 8ce9d7bc7..201009513 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -1,7 +1,7 @@ -const { Tools, Constants } = require('librechat-data-provider'); const { SerpAPI } = require('@langchain/community/tools/serpapi'); const { Calculator } = require('@langchain/community/tools/calculator'); const { createCodeExecutionTool, EnvVar } = require('@librechat/agents'); +const { Tools, Constants, EToolResources } = require('librechat-data-provider'); const { getUserPluginAuthValue } = require('~/server/services/PluginService'); const { availableTools, @@ -18,6 +18,7 @@ const { StructuredWolfram, createYouTubeTools, TavilySearchResults, + createOpenAIImageTools, } = require('../'); const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch'); @@ -157,7 +158,7 @@ const loadTools = async ({ }; const customConstructors = { - serpapi: async () => { + serpapi: async (_toolContextMap) => { const authFields = getAuthFields('serpapi'); let envVar = authFields[0] ?? ''; let apiKey = process.env[envVar]; @@ -170,11 +171,40 @@ const loadTools = async ({ gl: 'us', }); }, - youtube: async () => { + youtube: async (_toolContextMap) => { const authFields = getAuthFields('youtube'); const authValues = await loadAuthValues({ userId: user, authFields }); return createYouTubeTools(authValues); }, + image_gen_oai: async (toolContextMap) => { + const authFields = getAuthFields('image_gen_oai'); + const authValues = await loadAuthValues({ userId: user, authFields }); + const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? []; + let toolContext = ''; + for (let i = 0; i < imageFiles.length; i++) { + const file = imageFiles[i]; + if (!file) { + continue; + } + if (i === 0) { + toolContext = + 'Image files provided in this request (their image IDs listed in order of appearance) available for image editing:'; + } + toolContext += `\n\t- ${file.file_id}`; + if (i === imageFiles.length - 1) { + toolContext += `\n\nInclude any you need in the \`image_ids\` array when calling \`${EToolResources.image_edit}_oai\`. You may also include previously referenced or generated image IDs.`; + } + } + if (toolContext) { + toolContextMap.image_edit_oai = toolContext; + } + return createOpenAIImageTools({ + ...authValues, + isAgent: !!agent, + req: options.req, + imageFiles, + }); + }, }; const requestedTools = {}; @@ -200,6 +230,7 @@ const loadTools = async ({ serpapi: { location: 'Austin,Texas,United States', hl: 'en', gl: 'us' }, }; + /** @type {Record} */ const toolContextMap = {}; const remainingTools = []; const appTools = options.req?.app?.locals?.availableTools ?? {}; @@ -246,7 +277,7 @@ const loadTools = async ({ } if (customConstructors[tool]) { - requestedTools[tool] = customConstructors[tool]; + requestedTools[tool] = async () => customConstructors[tool](toolContextMap); continue; } diff --git a/api/models/File.js b/api/models/File.js index 87c91003e..4d9499447 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -1,4 +1,5 @@ const mongoose = require('mongoose'); +const { EToolResources } = require('librechat-data-provider'); const { fileSchema } = require('@librechat/data-schemas'); const { logger } = require('~/config'); @@ -8,7 +9,7 @@ const File = mongoose.model('File', fileSchema); * Finds a file by its file_id with additional query options. * @param {string} file_id - The unique identifier of the file. * @param {object} options - Query options for filtering, projection, etc. - * @returns {Promise} A promise that resolves to the file document or null. + * @returns {Promise} A promise that resolves to the file document or null. */ const findFileById = async (file_id, options = {}) => { return await File.findOne({ file_id, ...options }).lean(); @@ -20,7 +21,7 @@ const findFileById = async (file_id, options = {}) => { * @param {Object} [_sortOptions] - Optional sort parameters. * @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results. * Default excludes the 'text' field. - * @returns {Promise>} A promise that resolves to an array of file documents. + * @returns {Promise>} A promise that resolves to an array of file documents. */ const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => { const sortOptions = { updatedAt: -1, ..._sortOptions }; @@ -30,9 +31,10 @@ const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => { /** * Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs * @param {string[]} fileIds - Array of file_id strings to search for - * @returns {Promise>} Files that match the criteria + * @param {Set} toolResourceSet - Optional filter for tool resources + * @returns {Promise>} Files that match the criteria */ -const getToolFilesByIds = async (fileIds) => { +const getToolFilesByIds = async (fileIds, toolResourceSet) => { if (!fileIds || !fileIds.length) { return []; } @@ -40,9 +42,19 @@ const getToolFilesByIds = async (fileIds) => { try { const filter = { file_id: { $in: fileIds }, - $or: [{ embedded: true }, { 'metadata.fileIdentifier': { $exists: true } }], }; + if (toolResourceSet.size) { + filter.$or = []; + } + + if (toolResourceSet.has(EToolResources.file_search)) { + filter.$or.push({ embedded: true }); + } + if (toolResourceSet.has(EToolResources.execute_code)) { + filter.$or.push({ 'metadata.fileIdentifier': { $exists: true } }); + } + const selectFields = { text: 0 }; const sortOptions = { updatedAt: -1 }; @@ -55,9 +67,9 @@ const getToolFilesByIds = async (fileIds) => { /** * Creates a new file with a TTL of 1 hour. - * @param {IMongoFile} data - The file data to be created, must contain file_id. + * @param {MongoFile} data - The file data to be created, must contain file_id. * @param {boolean} disableTTL - Whether to disable the TTL. - * @returns {Promise} A promise that resolves to the created file document. + * @returns {Promise} A promise that resolves to the created file document. */ const createFile = async (data, disableTTL) => { const fileData = { @@ -77,8 +89,8 @@ const createFile = async (data, disableTTL) => { /** * Updates a file identified by file_id with new data and removes the TTL. - * @param {IMongoFile} data - The data to update, must contain file_id. - * @returns {Promise} A promise that resolves to the updated file document. + * @param {MongoFile} data - The data to update, must contain file_id. + * @returns {Promise} A promise that resolves to the updated file document. */ const updateFile = async (data) => { const { file_id, ...update } = data; @@ -91,8 +103,8 @@ const updateFile = async (data) => { /** * Increments the usage of a file identified by file_id. - * @param {IMongoFile} data - The data to update, must contain file_id and the increment value for usage. - * @returns {Promise} A promise that resolves to the updated file document. + * @param {MongoFile} data - The data to update, must contain file_id and the increment value for usage. + * @returns {Promise} A promise that resolves to the updated file document. */ const updateFileUsage = async (data) => { const { file_id, inc = 1 } = data; @@ -106,7 +118,7 @@ const updateFileUsage = async (data) => { /** * Deletes a file identified by file_id. * @param {string} file_id - The unique identifier of the file to delete. - * @returns {Promise} A promise that resolves to the deleted file document or null. + * @returns {Promise} A promise that resolves to the deleted file document or null. */ const deleteFile = async (file_id) => { return await File.findOneAndDelete({ file_id }).lean(); @@ -115,7 +127,7 @@ const deleteFile = async (file_id) => { /** * Deletes a file identified by a filter. * @param {object} filter - The filter criteria to apply. - * @returns {Promise} A promise that resolves to the deleted file document or null. + * @returns {Promise} A promise that resolves to the deleted file document or null. */ const deleteFileByFilter = async (filter) => { return await File.findOneAndDelete(filter).lean(); diff --git a/api/models/Share.js b/api/models/Share.js index a8bfbce7f..8611d01bc 100644 --- a/api/models/Share.js +++ b/api/models/Share.js @@ -52,6 +52,14 @@ function anonymizeMessages(messages, newConvoId) { const newMessageId = anonymizeMessageId(message.messageId); idMap.set(message.messageId, newMessageId); + const anonymizedAttachments = message.attachments?.map((attachment) => { + return { + ...attachment, + messageId: newMessageId, + conversationId: newConvoId, + }; + }); + return { ...message, messageId: newMessageId, @@ -61,6 +69,7 @@ function anonymizeMessages(messages, newConvoId) { model: message.model?.startsWith('asst_') ? anonymizeAssistantId(message.model) : message.model, + attachments: anonymizedAttachments, }; }); } diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 6622ec381..4ee57df67 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -246,7 +246,11 @@ function createToolEndCallback({ req, res, artifactPromises }) { if (output.artifact.content) { /** @type {FormattedContent[]} */ const content = output.artifact.content; - for (const part of content) { + for (let i = 0; i < content.length; i++) { + const part = content[i]; + if (!part) { + continue; + } if (part.type !== 'image_url') { continue; } @@ -254,8 +258,10 @@ function createToolEndCallback({ req, res, artifactPromises }) { artifactPromises.push( (async () => { const filename = `${output.name}_${output.tool_call_id}_img_${nanoid()}`; + const file_id = output.artifact.file_ids?.[i]; const file = await saveBase64Image(url, { req, + file_id, filename, endpoint: metadata.provider, context: FileContext.image_generation, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index eaff058bf..e26ed0884 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -3,6 +3,7 @@ const { Constants, ErrorTypes, EModelEndpoint, + EToolResources, getResponseSender, AgentCapabilities, providerEndpointMap, @@ -41,12 +42,19 @@ const providerConfigMap = { }; /** - * @param {ServerRequest} req - * @param {Promise> | undefined} _attachments - * @param {AgentToolResources | undefined} _tool_resources + * @param {Object} params + * @param {ServerRequest} params.req + * @param {Promise> | undefined} [params.attachments] + * @param {Set} params.requestFileSet + * @param {AgentToolResources | undefined} [params.tool_resources] * @returns {Promise<{ attachments: Array | undefined, tool_resources: AgentToolResources | undefined }>} */ -const primeResources = async (req, _attachments, _tool_resources) => { +const primeResources = async ({ + req, + attachments: _attachments, + tool_resources: _tool_resources, + requestFileSet, +}) => { try { /** @type {Array | undefined} */ let attachments; @@ -54,7 +62,7 @@ const primeResources = async (req, _attachments, _tool_resources) => { const isOCREnabled = (req.app.locals?.[EModelEndpoint.agents]?.capabilities ?? []).includes( AgentCapabilities.ocr, ); - if (tool_resources.ocr?.file_ids && isOCREnabled) { + if (tool_resources[EToolResources.ocr]?.file_ids && isOCREnabled) { const context = await getFiles( { file_id: { $in: tool_resources.ocr.file_ids }, @@ -79,17 +87,28 @@ const primeResources = async (req, _attachments, _tool_resources) => { continue; } if (file.metadata?.fileIdentifier) { - const execute_code = tool_resources.execute_code ?? {}; + const execute_code = tool_resources[EToolResources.execute_code] ?? {}; if (!execute_code.files) { - tool_resources.execute_code = { ...execute_code, files: [] }; + tool_resources[EToolResources.execute_code] = { ...execute_code, files: [] }; } - tool_resources.execute_code.files.push(file); + tool_resources[EToolResources.execute_code].files.push(file); } else if (file.embedded === true) { - const file_search = tool_resources.file_search ?? {}; + const file_search = tool_resources[EToolResources.file_search] ?? {}; if (!file_search.files) { - tool_resources.file_search = { ...file_search, files: [] }; + tool_resources[EToolResources.file_search] = { ...file_search, files: [] }; } - tool_resources.file_search.files.push(file); + tool_resources[EToolResources.file_search].files.push(file); + } else if ( + requestFileSet.has(file.file_id) && + file.type.startsWith('image') && + file.height && + file.width + ) { + const image_edit = tool_resources[EToolResources.image_edit] ?? {}; + if (!image_edit.files) { + tool_resources[EToolResources.image_edit] = { ...image_edit, files: [] }; + } + tool_resources[EToolResources.image_edit].files.push(file); } attachments.push(file); @@ -146,7 +165,14 @@ const initializeAgentOptions = async ({ (agent.model_parameters?.resendFiles ?? true) === true ) { const fileIds = (await getConvoFiles(req.body.conversationId)) ?? []; - const toolFiles = await getToolFilesByIds(fileIds); + /** @type {Set} */ + const toolResourceSet = new Set(); + for (const tool of agent.tools) { + if (EToolResources[tool]) { + toolResourceSet.add(EToolResources[tool]); + } + } + const toolFiles = await getToolFilesByIds(fileIds, toolResourceSet); if (requestFiles.length || toolFiles.length) { currentFiles = await processFiles(requestFiles.concat(toolFiles)); } @@ -154,11 +180,12 @@ const initializeAgentOptions = async ({ currentFiles = await processFiles(requestFiles); } - const { attachments, tool_resources } = await primeResources( + const { attachments, tool_resources } = await primeResources({ req, - currentFiles, - agent.tool_resources, - ); + attachments: currentFiles, + tool_resources: agent.tool_resources, + requestFileSet: new Set(requestFiles.map((file) => file.file_id)), + }); const provider = agent.provider; const { tools, toolContextMap } = await loadAgentTools({ diff --git a/api/server/services/Files/Local/crud.js b/api/server/services/Files/Local/crud.js index c2bb75c12..783230f2f 100644 --- a/api/server/services/Files/Local/crud.js +++ b/api/server/services/Files/Local/crud.js @@ -309,6 +309,24 @@ function getLocalFileStream(req, filepath) { throw new Error(`Invalid file path: ${filepath}`); } + return fs.createReadStream(fullPath); + } else if (filepath.includes('/images/')) { + const basePath = filepath.split('/images/')[1]; + + if (!basePath) { + logger.warn(`Invalid base path: ${filepath}`); + throw new Error(`Invalid file path: ${filepath}`); + } + + const fullPath = path.join(req.app.locals.paths.imageOutput, basePath); + const publicDir = req.app.locals.paths.imageOutput; + + const rel = path.relative(publicDir, fullPath); + if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) { + logger.warn(`Invalid relative file path: ${filepath}`); + throw new Error(`Invalid file path: ${filepath}`); + } + return fs.createReadStream(fullPath); } return fs.createReadStream(filepath); diff --git a/api/server/services/Files/S3/crud.js b/api/server/services/Files/S3/crud.js index e685c8c8c..10c04106d 100644 --- a/api/server/services/Files/S3/crud.js +++ b/api/server/services/Files/S3/crud.js @@ -358,10 +358,10 @@ async function getNewS3URL(currentURL) { /** * Refreshes S3 URLs for an array of files if they're expired or close to expiring * - * @param {IMongoFile[]} files - Array of file documents + * @param {MongoFile[]} files - Array of file documents * @param {(files: MongoFile[]) => Promise} batchUpdateFiles - Function to update files in the database * @param {number} [bufferSeconds=3600] - Buffer time in seconds to check for expiration - * @returns {Promise} The files with refreshed URLs if needed + * @returns {Promise} The files with refreshed URLs if needed */ async function refreshS3FileUrls(files, batchUpdateFiles, bufferSeconds = 3600) { if (!files || !Array.isArray(files) || files.length === 0) { diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 046d4e9bf..f3e7df5a7 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -16,13 +16,18 @@ const { validateAndParseOpenAPISpec, } = require('librechat-data-provider'); const { - loadActionSets, createActionTool, decryptMetadata, + loadActionSets, domainParser, } = require('./ActionService'); +const { + createOpenAIImageTools, + createYouTubeTools, + manifestToolMap, + toolkits, +} = require('~/app/clients/tools'); const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process'); -const { createYouTubeTools, manifestToolMap, toolkits } = require('~/app/clients/tools'); const { isActionDomainAllowed } = require('~/server/services/domains'); const { getEndpointsConfig } = require('~/server/services/Config'); const { recordUsage } = require('~/server/services/Threads'); @@ -104,7 +109,11 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] }) } /** Basic Tools; schema: { input: string } */ - const basicToolInstances = [new Calculator(), ...createYouTubeTools({ override: true })]; + const basicToolInstances = [ + new Calculator(), + ...createOpenAIImageTools({ override: true }), + ...createYouTubeTools({ override: true }), + ]; for (const toolInstance of basicToolInstances) { const formattedTool = formatToOpenAIAssistantTool(toolInstance); let toolName = formattedTool[Tools.function].name; diff --git a/api/typedefs.js b/api/typedefs.js index 24dd29a93..0aab97c42 100644 --- a/api/typedefs.js +++ b/api/typedefs.js @@ -7,6 +7,11 @@ * @typedef {import('openai').OpenAI} OpenAI * @memberof typedefs */ +/** + * @exports OpenAIImagesResponse + * @typedef {Promise} OpenAIImagesResponse + * @memberof typedefs + */ /** * @exports ServerRequest @@ -14,6 +19,18 @@ * @memberof typedefs */ +/** + * @template T + * @typedef {ReadableStream | NodeJS.ReadableStream} NodeStream + * @memberof typedefs + */ + +/** + * @template T + * @typedef {(req: ServerRequest, filepath: string) => Promise>} NodeStreamDownloader + * @memberof typedefs + */ + /** * @exports ServerResponse * @typedef {import('express').Response} ServerResponse @@ -816,8 +833,9 @@ /** * @typedef {Partial & { * message?: string, - * signal?: AbortSignal - * memory?: ConversationSummaryBufferMemory + * signal?: AbortSignal, + * memory?: ConversationSummaryBufferMemory, + * tool_resources?: AgentToolResources, * }} LoadToolOptions * @memberof typedefs */ diff --git a/client/package.json b/client/package.json index 59e3dd831..7273ad9c4 100644 --- a/client/package.json +++ b/client/package.json @@ -86,7 +86,7 @@ "react-i18next": "^15.4.0", "react-lazy-load-image-component": "^1.6.0", "react-markdown": "^9.0.1", - "react-resizable-panels": "^2.1.7", + "react-resizable-panels": "^2.1.8", "react-router-dom": "^6.11.2", "react-speech-recognition": "^3.10.0", "react-textarea-autosize": "^8.4.0", diff --git a/client/public/assets/image_gen_oai.png b/client/public/assets/image_gen_oai.png new file mode 100644 index 0000000000000000000000000000000000000000..e1762e709186200452ac9416c5e6e0f792166e31 GIT binary patch literal 38419 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

SrP_;^VT1C`=hyE_zU6PL@O7i$JA77-+3hg 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 void; diff --git a/client/src/components/Artifacts/Artifact.tsx b/client/src/components/Artifacts/Artifact.tsx index d1bf22ef5..db193fe1e 100644 --- a/client/src/components/Artifacts/Artifact.tsx +++ b/client/src/components/Artifacts/Artifact.tsx @@ -34,6 +34,10 @@ export const artifactPlugin: Pluggable = () => { }; }; +const defaultTitle = 'untitled'; +const defaultType = 'unknown'; +const defaultIdentifier = 'lc-no-identifier'; + export function Artifact({ node, ...props @@ -58,15 +62,18 @@ export function Artifact({ const content = extractContent(props.children); logger.log('artifacts', 'updateArtifact: content.length', content.length); - const title = props.title ?? 'Untitled Artifact'; - const type = props.type ?? 'unknown'; - const identifier = props.identifier ?? 'no-identifier'; + const title = props.title ?? defaultTitle; + const type = props.type ?? defaultType; + const identifier = props.identifier ?? defaultIdentifier; const artifactKey = `${identifier}_${type}_${title}_${messageId}` .replace(/\s+/g, '_') .toLowerCase(); throttledUpdateRef.current(() => { const now = Date.now(); + if (artifactKey === `${defaultIdentifier}_${defaultType}_${defaultTitle}_${messageId}`) { + return; + } const currentArtifact: Artifact = { id: artifactKey, diff --git a/client/src/components/Artifacts/ArtifactButton.tsx b/client/src/components/Artifacts/ArtifactButton.tsx index d8fa55770..67082f490 100644 --- a/client/src/components/Artifacts/ArtifactButton.tsx +++ b/client/src/components/Artifacts/ArtifactButton.tsx @@ -1,4 +1,4 @@ -import { useSetRecoilState } from 'recoil'; +import { useSetRecoilState, useResetRecoilState } from 'recoil'; import type { Artifact } from '~/common'; import FilePreview from '~/components/Chat/Input/Files/FilePreview'; import { useLocalize } from '~/hooks'; @@ -8,7 +8,8 @@ import store from '~/store'; const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => { const localize = useLocalize(); const setVisible = useSetRecoilState(store.artifactsVisible); - const setArtifactId = useSetRecoilState(store.currentArtifactId); + const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId); + const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); if (artifact === null || artifact === undefined) { return null; } @@ -19,12 +20,15 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {