mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-11 18:42:36 +01:00
💎 fix: Gemini Image Gen Tool Vertex AI Auth and File Storage (#11923)
* chore: saveToCloudStorage function and enhance error handling - Removed unnecessary parameters and streamlined the logic for saving images to cloud storage. - Introduced buffer handling for base64 image data and improved the integration with file strategy functions. - Enhanced error handling during local image saving to ensure robustness. - Updated the createGeminiImageTool function to reflect changes in the saveToCloudStorage implementation. * refactor: streamline image persistence logic in GeminiImageGen - Consolidated image saving functionality by renaming and refactoring the saveToCloudStorage function to persistGeneratedImage. - Improved error handling and logging for image persistence operations. - Enhanced the replaceUnwantedChars function to better sanitize input strings. - Updated createGeminiImageTool to reflect changes in image handling and ensure consistent behavior across storage strategies. * fix: clean up GeminiImageGen by removing unused functions and improving logging - Removed the getSafeFormat and persistGeneratedImage functions to streamline image handling. - Updated logging in createGeminiImageTool for clarity and consistency. - Consolidated imports by eliminating unused dependencies, enhancing code maintainability. * chore: update environment configuration and manifest for unused GEMINI_VERTEX_ENABLED - Removed the Vertex AI configuration option from .env.example to simplify setup. - Updated the manifest.json to reflect the removal of the Vertex AI dependency in the authentication field. - Cleaned up the createGeminiImageTool function by eliminating unused fields related to Vertex AI, streamlining the code. * fix: update loadAuthValues call in loadTools function for GeminiImageGen tool - Modified the loadAuthValues function call to include throwError: false, preventing exceptions on authentication failures. - Removed the unused processFileURL parameter from the tool context object, streamlining the code. * refactor: streamline GoogleGenAI initialization in GeminiImageGen - Removed unused file system access check for Google application credentials, simplifying the environment setup. - Added googleAuthOptions to the GoogleGenAI instantiation, enhancing the configuration for authentication. * fix: update Gemini API Key label and description in manifest.json - Changed the label to indicate that the Gemini API Key is optional. - Revised the description to clarify usage with Vertex AI and service accounts, enhancing user guidance. * fix: enhance abort signal handling in createGeminiImageTool - Introduced derivedSignal to manage abort events during image generation, improving responsiveness to cancellation requests. - Added an abortHandler to log when image generation is aborted, enhancing debugging capabilities. - Ensured proper cleanup of event listeners in the finally block to prevent memory leaks. * fix: update authentication handling for plugins to support optional fields - Added support for optional authentication fields in the manifest and PluginAuthForm. - Updated the checkPluginAuth function to correctly validate plugins with optional fields. - Enhanced tests to cover scenarios with optional authentication fields, ensuring accurate validation logic.
This commit is contained in:
parent
1d0a4c501f
commit
f3eb197675
8 changed files with 136 additions and 181 deletions
|
|
@ -243,10 +243,6 @@ GOOGLE_KEY=user_provided
|
||||||
# Option A: Use dedicated Gemini API key for image generation
|
# Option A: Use dedicated Gemini API key for image generation
|
||||||
# GEMINI_API_KEY=your-gemini-api-key
|
# GEMINI_API_KEY=your-gemini-api-key
|
||||||
|
|
||||||
# Option B: Use Vertex AI (no API key needed, uses service account)
|
|
||||||
# Set this to enable Vertex AI and allow tool without requiring API keys
|
|
||||||
# GEMINI_VERTEX_ENABLED=true
|
|
||||||
|
|
||||||
# Vertex AI model for image generation (defaults to gemini-2.5-flash-image)
|
# Vertex AI model for image generation (defaults to gemini-2.5-flash-image)
|
||||||
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
|
# GEMINI_IMAGE_MODEL=gemini-2.5-flash-image
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,9 +161,10 @@
|
||||||
"icon": "assets/gemini_image_gen.svg",
|
"icon": "assets/gemini_image_gen.svg",
|
||||||
"authConfig": [
|
"authConfig": [
|
||||||
{
|
{
|
||||||
"authField": "GEMINI_API_KEY||GOOGLE_KEY||GEMINI_VERTEX_ENABLED",
|
"authField": "GEMINI_API_KEY||GOOGLE_KEY||GOOGLE_SERVICE_KEY_FILE",
|
||||||
"label": "Gemini API Key (Optional if Vertex AI is configured)",
|
"label": "Gemini API Key (optional)",
|
||||||
"description": "Your Google Gemini API Key from <a href='https://aistudio.google.com/app/apikey' target='_blank'>Google AI Studio</a>. Leave blank if using Vertex AI with service account."
|
"description": "Your Google Gemini API Key from <a href='https://aistudio.google.com/app/apikey' target='_blank'>Google AI Studio</a>. Leave blank to use Vertex AI with a service account (GOOGLE_SERVICE_KEY_FILE or api/data/auth.json).",
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
const { v4 } = require('uuid');
|
const { v4 } = require('uuid');
|
||||||
|
|
@ -6,12 +5,7 @@ const { ProxyAgent } = require('undici');
|
||||||
const { GoogleGenAI } = require('@google/genai');
|
const { GoogleGenAI } = require('@google/genai');
|
||||||
const { tool } = require('@langchain/core/tools');
|
const { tool } = require('@langchain/core/tools');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const {
|
const { ContentTypes, EImageOutputType } = require('librechat-data-provider');
|
||||||
FileContext,
|
|
||||||
ContentTypes,
|
|
||||||
FileSources,
|
|
||||||
EImageOutputType,
|
|
||||||
} = require('librechat-data-provider');
|
|
||||||
const {
|
const {
|
||||||
geminiToolkit,
|
geminiToolkit,
|
||||||
loadServiceKey,
|
loadServiceKey,
|
||||||
|
|
@ -59,17 +53,12 @@ const displayMessage =
|
||||||
* @returns {string} - The processed string
|
* @returns {string} - The processed string
|
||||||
*/
|
*/
|
||||||
function replaceUnwantedChars(inputString) {
|
function replaceUnwantedChars(inputString) {
|
||||||
return inputString?.replace(/[^\w\s\-_.,!?()]/g, '') || '';
|
return (
|
||||||
}
|
inputString
|
||||||
|
?.replace(/\r\n|\r|\n/g, ' ')
|
||||||
/**
|
.replace(/"/g, '')
|
||||||
* Validate and sanitize image format
|
.trim() || ''
|
||||||
* @param {string} format - The format to validate
|
);
|
||||||
* @returns {string} - Safe format
|
|
||||||
*/
|
|
||||||
function getSafeFormat(format) {
|
|
||||||
const allowedFormats = ['png', 'jpg', 'jpeg', 'webp', 'gif'];
|
|
||||||
return allowedFormats.includes(format?.toLowerCase()) ? format.toLowerCase() : 'png';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -117,11 +106,8 @@ async function initializeGeminiClient(options = {}) {
|
||||||
return new GoogleGenAI({ apiKey: googleKey });
|
return new GoogleGenAI({ apiKey: googleKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to Vertex AI with service account
|
|
||||||
logger.debug('[GeminiImageGen] Using Vertex AI with service account');
|
logger.debug('[GeminiImageGen] Using Vertex AI with service account');
|
||||||
const credentialsPath = getDefaultServiceKeyPath();
|
const credentialsPath = getDefaultServiceKeyPath();
|
||||||
|
|
||||||
// Use loadServiceKey for consistent loading (supports file paths, JSON strings, base64)
|
|
||||||
const serviceKey = await loadServiceKey(credentialsPath);
|
const serviceKey = await loadServiceKey(credentialsPath);
|
||||||
|
|
||||||
if (!serviceKey || !serviceKey.project_id) {
|
if (!serviceKey || !serviceKey.project_id) {
|
||||||
|
|
@ -131,75 +117,14 @@ async function initializeGeminiClient(options = {}) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set GOOGLE_APPLICATION_CREDENTIALS for any Google Cloud SDK dependencies
|
|
||||||
try {
|
|
||||||
await fs.promises.access(credentialsPath);
|
|
||||||
process.env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
|
|
||||||
} catch {
|
|
||||||
// File doesn't exist, skip setting env var
|
|
||||||
}
|
|
||||||
|
|
||||||
return new GoogleGenAI({
|
return new GoogleGenAI({
|
||||||
vertexai: true,
|
vertexai: true,
|
||||||
project: serviceKey.project_id,
|
project: serviceKey.project_id,
|
||||||
location: process.env.GOOGLE_LOC || process.env.GOOGLE_CLOUD_LOCATION || 'global',
|
location: process.env.GOOGLE_LOC || process.env.GOOGLE_CLOUD_LOCATION || 'global',
|
||||||
|
googleAuthOptions: { credentials: serviceKey },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Save image to local filesystem
|
|
||||||
* @param {string} base64Data - Base64 encoded image data
|
|
||||||
* @param {string} format - Image format
|
|
||||||
* @param {string} userId - User ID
|
|
||||||
* @returns {Promise<string>} - The relative URL
|
|
||||||
*/
|
|
||||||
async function saveImageLocally(base64Data, format, userId) {
|
|
||||||
const safeFormat = getSafeFormat(format);
|
|
||||||
const safeUserId = userId ? path.basename(userId) : 'default';
|
|
||||||
const imageName = `gemini-img-${v4()}.${safeFormat}`;
|
|
||||||
const userDir = path.join(process.cwd(), 'client/public/images', safeUserId);
|
|
||||||
|
|
||||||
await fs.promises.mkdir(userDir, { recursive: true });
|
|
||||||
|
|
||||||
const filePath = path.join(userDir, imageName);
|
|
||||||
await fs.promises.writeFile(filePath, Buffer.from(base64Data, 'base64'));
|
|
||||||
|
|
||||||
logger.debug('[GeminiImageGen] Image saved locally to:', filePath);
|
|
||||||
return `/images/${safeUserId}/${imageName}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save image to cloud storage
|
|
||||||
* @param {Object} params - Parameters
|
|
||||||
* @returns {Promise<string|null>} - The storage URL or null
|
|
||||||
*/
|
|
||||||
async function saveToCloudStorage({ base64Data, format, processFileURL, fileStrategy, userId }) {
|
|
||||||
if (!processFileURL || !fileStrategy || !userId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const safeFormat = getSafeFormat(format);
|
|
||||||
const safeUserId = path.basename(userId);
|
|
||||||
const dataURL = `data:image/${safeFormat};base64,${base64Data}`;
|
|
||||||
const imageName = `gemini-img-${v4()}.${safeFormat}`;
|
|
||||||
|
|
||||||
const result = await processFileURL({
|
|
||||||
URL: dataURL,
|
|
||||||
basePath: 'images',
|
|
||||||
userId: safeUserId,
|
|
||||||
fileName: imageName,
|
|
||||||
fileStrategy,
|
|
||||||
context: FileContext.image_generation,
|
|
||||||
});
|
|
||||||
|
|
||||||
return result.filepath;
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[GeminiImageGen] Error saving to cloud storage:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert image files to Gemini inline data format
|
* Convert image files to Gemini inline data format
|
||||||
* @param {Object} params - Parameters
|
* @param {Object} params - Parameters
|
||||||
|
|
@ -390,34 +315,18 @@ function createGeminiImageTool(fields = {}) {
|
||||||
throw new Error('This tool is only available for agents.');
|
throw new Error('This tool is only available for agents.');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip validation during tool creation - validation happens at runtime in initializeGeminiClient
|
const { req, imageFiles = [], userId, fileStrategy, GEMINI_API_KEY, GOOGLE_KEY } = fields;
|
||||||
// This allows the tool to be added to agents when using Vertex AI without requiring API keys
|
|
||||||
// The actual credentials check happens when the tool is invoked
|
|
||||||
|
|
||||||
const {
|
|
||||||
req,
|
|
||||||
imageFiles = [],
|
|
||||||
processFileURL,
|
|
||||||
userId,
|
|
||||||
fileStrategy,
|
|
||||||
GEMINI_API_KEY,
|
|
||||||
GOOGLE_KEY,
|
|
||||||
// GEMINI_VERTEX_ENABLED is used for auth validation only (not used in code)
|
|
||||||
// When set as env var, it signals Vertex AI is configured and bypasses API key requirement
|
|
||||||
} = fields;
|
|
||||||
|
|
||||||
const imageOutputType = fields.imageOutputType || EImageOutputType.PNG;
|
const imageOutputType = fields.imageOutputType || EImageOutputType.PNG;
|
||||||
|
|
||||||
const geminiImageGenTool = tool(
|
const geminiImageGenTool = tool(
|
||||||
async ({ prompt, image_ids, aspectRatio, imageSize }, _runnableConfig) => {
|
async ({ prompt, image_ids, aspectRatio, imageSize }, runnableConfig) => {
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
throw new Error('Missing required field: prompt');
|
throw new Error('Missing required field: prompt');
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('[GeminiImageGen] Generating image with prompt:', prompt?.substring(0, 100));
|
logger.debug('[GeminiImageGen] Generating image', { aspectRatio, imageSize });
|
||||||
logger.debug('[GeminiImageGen] Options:', { aspectRatio, imageSize });
|
|
||||||
|
|
||||||
// Initialize Gemini client with user-provided credentials
|
|
||||||
let ai;
|
let ai;
|
||||||
try {
|
try {
|
||||||
ai = await initializeGeminiClient({
|
ai = await initializeGeminiClient({
|
||||||
|
|
@ -432,10 +341,8 @@ function createGeminiImageTool(fields = {}) {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build request contents
|
|
||||||
const contents = [{ text: replaceUnwantedChars(prompt) }];
|
const contents = [{ text: replaceUnwantedChars(prompt) }];
|
||||||
|
|
||||||
// Add context images if provided
|
|
||||||
if (image_ids?.length > 0) {
|
if (image_ids?.length > 0) {
|
||||||
const contextImages = await convertImagesToInlineData({
|
const contextImages = await convertImagesToInlineData({
|
||||||
imageFiles,
|
imageFiles,
|
||||||
|
|
@ -447,28 +354,34 @@ function createGeminiImageTool(fields = {}) {
|
||||||
logger.debug('[GeminiImageGen] Added', contextImages.length, 'context images');
|
logger.debug('[GeminiImageGen] Added', contextImages.length, 'context images');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate image
|
|
||||||
let apiResponse;
|
let apiResponse;
|
||||||
const geminiModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
|
const geminiModel = process.env.GEMINI_IMAGE_MODEL || 'gemini-2.5-flash-image';
|
||||||
try {
|
const config = {
|
||||||
// Build config with optional imageConfig
|
responseModalities: ['TEXT', 'IMAGE'],
|
||||||
const config = {
|
};
|
||||||
responseModalities: ['TEXT', 'IMAGE'],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add imageConfig if aspectRatio or imageSize is specified
|
const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image');
|
||||||
// Note: gemini-2.5-flash-image doesn't support imageSize
|
if (aspectRatio || (imageSize && supportsImageSize)) {
|
||||||
const supportsImageSize = !geminiModel.includes('gemini-2.5-flash-image');
|
config.imageConfig = {};
|
||||||
if (aspectRatio || (imageSize && supportsImageSize)) {
|
if (aspectRatio) {
|
||||||
config.imageConfig = {};
|
config.imageConfig.aspectRatio = aspectRatio;
|
||||||
if (aspectRatio) {
|
|
||||||
config.imageConfig.aspectRatio = aspectRatio;
|
|
||||||
}
|
|
||||||
if (imageSize && supportsImageSize) {
|
|
||||||
config.imageConfig.imageSize = imageSize;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (imageSize && supportsImageSize) {
|
||||||
|
config.imageConfig.imageSize = imageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let derivedSignal = null;
|
||||||
|
let abortHandler = null;
|
||||||
|
|
||||||
|
if (runnableConfig?.signal) {
|
||||||
|
derivedSignal = AbortSignal.any([runnableConfig.signal]);
|
||||||
|
abortHandler = () => logger.debug('[GeminiImageGen] Image generation aborted');
|
||||||
|
derivedSignal.addEventListener('abort', abortHandler, { once: true });
|
||||||
|
config.abortSignal = derivedSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
apiResponse = await ai.models.generateContent({
|
apiResponse = await ai.models.generateContent({
|
||||||
model: geminiModel,
|
model: geminiModel,
|
||||||
contents,
|
contents,
|
||||||
|
|
@ -480,9 +393,12 @@ function createGeminiImageTool(fields = {}) {
|
||||||
[{ type: ContentTypes.TEXT, text: `Image generation failed: ${error.message}` }],
|
[{ type: ContentTypes.TEXT, text: `Image generation failed: ${error.message}` }],
|
||||||
{ content: [], file_ids: [] },
|
{ content: [], file_ids: [] },
|
||||||
];
|
];
|
||||||
|
} finally {
|
||||||
|
if (abortHandler && derivedSignal) {
|
||||||
|
derivedSignal.removeEventListener('abort', abortHandler);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for safety blocks
|
|
||||||
const safetyBlock = checkForSafetyBlock(apiResponse);
|
const safetyBlock = checkForSafetyBlock(apiResponse);
|
||||||
if (safetyBlock) {
|
if (safetyBlock) {
|
||||||
logger.warn('[GeminiImageGen] Safety block:', safetyBlock);
|
logger.warn('[GeminiImageGen] Safety block:', safetyBlock);
|
||||||
|
|
@ -509,46 +425,7 @@ function createGeminiImageTool(fields = {}) {
|
||||||
const imageData = convertedBuffer.toString('base64');
|
const imageData = convertedBuffer.toString('base64');
|
||||||
const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : `image/${outputFormat}`;
|
const mimeType = outputFormat === 'jpeg' ? 'image/jpeg' : `image/${outputFormat}`;
|
||||||
|
|
||||||
logger.debug('[GeminiImageGen] Image format:', { outputFormat, mimeType });
|
|
||||||
|
|
||||||
let imageUrl;
|
|
||||||
const useLocalStorage = !fileStrategy || fileStrategy === FileSources.local;
|
|
||||||
|
|
||||||
if (useLocalStorage) {
|
|
||||||
try {
|
|
||||||
imageUrl = await saveImageLocally(imageData, outputFormat, userId);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[GeminiImageGen] Local save failed:', error);
|
|
||||||
imageUrl = `data:${mimeType};base64,${imageData}`;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const cloudUrl = await saveToCloudStorage({
|
|
||||||
base64Data: imageData,
|
|
||||||
format: outputFormat,
|
|
||||||
processFileURL,
|
|
||||||
fileStrategy,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (cloudUrl) {
|
|
||||||
imageUrl = cloudUrl;
|
|
||||||
} else {
|
|
||||||
// Fallback to local
|
|
||||||
try {
|
|
||||||
imageUrl = await saveImageLocally(imageData, outputFormat, userId);
|
|
||||||
} catch (_error) {
|
|
||||||
imageUrl = `data:${mimeType};base64,${imageData}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug('[GeminiImageGen] Image URL:', imageUrl);
|
|
||||||
|
|
||||||
// For the artifact, we need a data URL (same as OpenAI)
|
|
||||||
// The local file save is for persistence, but the response needs a data URL
|
|
||||||
const dataUrl = `data:${mimeType};base64,${imageData}`;
|
const dataUrl = `data:${mimeType};base64,${imageData}`;
|
||||||
|
|
||||||
// Return in content_and_artifact format (same as OpenAI)
|
|
||||||
const file_ids = [v4()];
|
const file_ids = [v4()];
|
||||||
const content = [
|
const content = [
|
||||||
{
|
{
|
||||||
|
|
@ -567,8 +444,7 @@ function createGeminiImageTool(fields = {}) {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Record token usage for balance tracking (don't await to avoid blocking response)
|
const conversationId = runnableConfig?.configurable?.thread_id;
|
||||||
const conversationId = _runnableConfig?.configurable?.thread_id;
|
|
||||||
recordTokenUsage({
|
recordTokenUsage({
|
||||||
usageMetadata: apiResponse.usageMetadata,
|
usageMetadata: apiResponse.usageMetadata,
|
||||||
req,
|
req,
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ const loadTools = async ({
|
||||||
},
|
},
|
||||||
gemini_image_gen: async (toolContextMap) => {
|
gemini_image_gen: async (toolContextMap) => {
|
||||||
const authFields = getAuthFields('gemini_image_gen');
|
const authFields = getAuthFields('gemini_image_gen');
|
||||||
const authValues = await loadAuthValues({ userId: user, authFields });
|
const authValues = await loadAuthValues({ userId: user, authFields, throwError: false });
|
||||||
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
|
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
|
||||||
const toolContext = buildImageToolContext({
|
const toolContext = buildImageToolContext({
|
||||||
imageFiles,
|
imageFiles,
|
||||||
|
|
@ -222,7 +222,6 @@ const loadTools = async ({
|
||||||
isAgent: !!agent,
|
isAgent: !!agent,
|
||||||
req: options.req,
|
req: options.req,
|
||||||
imageFiles,
|
imageFiles,
|
||||||
processFileURL: options.processFileURL,
|
|
||||||
userId: user,
|
userId: user,
|
||||||
fileStrategy,
|
fileStrategy,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
|
||||||
|
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const authConfig = plugin?.authConfig ?? [];
|
const authConfig = plugin?.authConfig ?? [];
|
||||||
|
const allFieldsOptional = authConfig.length > 0 && authConfig.every((c) => c.optional === true);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col items-center gap-2">
|
<div className="flex w-full flex-col items-center gap-2">
|
||||||
|
|
@ -38,6 +39,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
|
||||||
>
|
>
|
||||||
{authConfig.map((config: TPluginAuthConfig, i: number) => {
|
{authConfig.map((config: TPluginAuthConfig, i: number) => {
|
||||||
const authField = config.authField.split('||')[0];
|
const authField = config.authField.split('||')[0];
|
||||||
|
const isOptional = config.optional === true;
|
||||||
return (
|
return (
|
||||||
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
|
<div key={`${authField}-${i}`} className="flex w-full flex-col gap-1">
|
||||||
<label
|
<label
|
||||||
|
|
@ -55,19 +57,24 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
|
||||||
aria-invalid={!!errors[authField]}
|
aria-invalid={!!errors[authField]}
|
||||||
aria-describedby={`${authField}-error`}
|
aria-describedby={`${authField}-error`}
|
||||||
aria-label={config.label}
|
aria-label={config.label}
|
||||||
aria-required="true"
|
aria-required={!isOptional}
|
||||||
/* autoFocus is generally disabled due to the fact that it can disorient users,
|
/* autoFocus is generally disabled due to the fact that it can disorient users,
|
||||||
* but in this case, the required field must be navigated to anyways, and the component's functionality
|
* but in this case, the required field must be navigated to anyways, and the component's functionality
|
||||||
* emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
|
* emulates that of a new modal opening, where users would expect focus to be shifted to the new content */
|
||||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||||
autoFocus={i === 0}
|
autoFocus={i === 0}
|
||||||
{...register(authField, {
|
{...register(
|
||||||
required: `${config.label} is required.`,
|
authField,
|
||||||
minLength: {
|
isOptional
|
||||||
value: 1,
|
? {}
|
||||||
message: `${config.label} must be at least 1 character long`,
|
: {
|
||||||
},
|
required: `${config.label} is required.`,
|
||||||
})}
|
minLength: {
|
||||||
|
value: 1,
|
||||||
|
message: `${config.label} must be at least 1 character long`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)}
|
||||||
className="flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"
|
className="flex h-10 max-h-10 w-full resize-none rounded-md border border-gray-200 bg-transparent px-3 py-2 text-sm text-gray-700 shadow-[0_0_10px_rgba(0,0,0,0.05)] outline-none placeholder:text-gray-400 focus:border-gray-400 focus:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-gray-400 focus:ring-opacity-0 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-500 dark:bg-gray-700 dark:text-gray-50 dark:shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:focus:border-gray-400 focus:dark:bg-gray-600 dark:focus:outline-none dark:focus:ring-0 dark:focus:ring-gray-400 dark:focus:ring-offset-0"
|
||||||
/>
|
/>
|
||||||
</HoverCardTrigger>
|
</HoverCardTrigger>
|
||||||
|
|
@ -82,7 +89,7 @@ function PluginAuthForm({ plugin, onSubmit, isEntityTool }: TPluginAuthFormProps
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<button
|
<button
|
||||||
disabled={!isDirty || !isValid || isSubmitting}
|
disabled={allFieldsOptional ? isSubmitting : !isDirty || !isValid || isSubmitting}
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-primary relative"
|
className="btn btn-primary relative"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -189,6 +189,77 @@ describe('format.ts helper functions', () => {
|
||||||
const result = checkPluginAuth(plugin);
|
const result = checkPluginAuth(plugin);
|
||||||
expect(result).toBe(true);
|
expect(result).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return true when auth field is marked as optional with no env vars', () => {
|
||||||
|
const plugin: TPlugin = {
|
||||||
|
name: 'Test',
|
||||||
|
pluginKey: 'test',
|
||||||
|
description: 'Test plugin',
|
||||||
|
authConfig: [
|
||||||
|
{ authField: 'MISSING_KEY', label: 'API Key', description: 'API Key', optional: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkPluginAuth(plugin);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when all auth fields are optional', () => {
|
||||||
|
const plugin: TPlugin = {
|
||||||
|
name: 'Test',
|
||||||
|
pluginKey: 'test',
|
||||||
|
description: 'Test plugin',
|
||||||
|
authConfig: [
|
||||||
|
{ authField: 'MISSING_KEY_A', label: 'Key A', description: 'Key A', optional: true },
|
||||||
|
{ authField: 'MISSING_KEY_B', label: 'Key B', description: 'Key B', optional: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkPluginAuth(plugin);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when required field is missing even if optional field exists', () => {
|
||||||
|
const plugin: TPlugin = {
|
||||||
|
name: 'Test',
|
||||||
|
pluginKey: 'test',
|
||||||
|
description: 'Test plugin',
|
||||||
|
authConfig: [
|
||||||
|
{ authField: 'MISSING_KEY', label: 'Required Key', description: 'Required' },
|
||||||
|
{
|
||||||
|
authField: 'OPTIONAL_KEY',
|
||||||
|
label: 'Optional Key',
|
||||||
|
description: 'Optional',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkPluginAuth(plugin);
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true when optional field has no env var but required field is satisfied', () => {
|
||||||
|
process.env.REQUIRED_KEY = 'valid-key';
|
||||||
|
|
||||||
|
const plugin: TPlugin = {
|
||||||
|
name: 'Test',
|
||||||
|
pluginKey: 'test',
|
||||||
|
description: 'Test plugin',
|
||||||
|
authConfig: [
|
||||||
|
{ authField: 'REQUIRED_KEY', label: 'Required Key', description: 'Required' },
|
||||||
|
{
|
||||||
|
authField: 'OPTIONAL_KEY',
|
||||||
|
label: 'Optional Key',
|
||||||
|
description: 'Optional',
|
||||||
|
optional: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = checkPluginAuth(plugin);
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getToolkitKey', () => {
|
describe('getToolkitKey', () => {
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => {
|
||||||
}
|
}
|
||||||
|
|
||||||
return plugin.authConfig.every((authFieldObj) => {
|
return plugin.authConfig.every((authFieldObj) => {
|
||||||
|
if (authFieldObj.optional === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const authFieldOptions = authFieldObj.authField.split('||');
|
const authFieldOptions = authFieldObj.authField.split('||');
|
||||||
let isFieldAuthenticated = false;
|
let isFieldAuthenticated = false;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -544,6 +544,7 @@ export const tPluginAuthConfigSchema = z.object({
|
||||||
authField: z.string(),
|
authField: z.string(),
|
||||||
label: z.string(),
|
label: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
|
optional: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type TPluginAuthConfig = z.infer<typeof tPluginAuthConfigSchema>;
|
export type TPluginAuthConfig = z.infer<typeof tPluginAuthConfigSchema>;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue