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 6e552c24..8ae71409 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 3a3b828e..61039695 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 3e02c2cc..6e0c9222 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 df436fb0..87b1884e 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 43be7a4e..55c1b1c5 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 00000000..85941a77 --- /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 8ce9d7bc..20100951 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 87c91003..4d949944 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 a8bfbce7..8611d01b 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 6622ec38..4ee57df6 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 eaff058b..e26ed088 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[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.file_search.files.push(file); + 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 c2bb75c1..783230f2 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 e685c8c8..10c04106 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 046d4e9b..f3e7df5a 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 24dd29a9..0aab97c4 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 59e3dd83..7273ad9c 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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 void; diff --git a/client/src/components/Artifacts/Artifact.tsx b/client/src/components/Artifacts/Artifact.tsx index d1bf22ef..db193fe1 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 d8fa5577..67082f49 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 }) => {