diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js index 72fc657333..9908e9239d 100644 --- a/Parse-Dashboard/app.js +++ b/Parse-Dashboard/app.js @@ -7,6 +7,7 @@ const Authentication = require('./Authentication.js'); const fs = require('fs'); const ConfigKeyCache = require('./configKeyCache.js'); const currentVersionFeatures = require('../package.json').parseDashboardFeatures; +const Parse = require('parse/node'); let newFeaturesInLatestVersion = []; @@ -50,8 +51,7 @@ function checkIfIconsExistForApps(apps, iconsFolder) { if ('ENOENT' == err.code) {// file does not exist console.warn('Icon with file name: ' + iconName + ' couldn\'t be found in icons folder!'); } else { - console.log( - 'An error occurd while checking for icons, please check permission!'); + console.warn('An error occurred while checking for icons, please check permission!'); } } else { //every thing was ok so for example you can read it and send it to client @@ -63,6 +63,11 @@ function checkIfIconsExistForApps(apps, iconsFolder) { module.exports = function(config, options) { options = options || {}; const app = express(); + + // Parse JSON and URL-encoded request bodies + app.use(express.json()); + app.use(express.urlencoded({ extended: true })); + // Serve public files. app.use(express.static(path.join(__dirname,'public'))); @@ -84,8 +89,12 @@ module.exports = function(config, options) { if (err.code !== 'EBADCSRFTOKEN') {return next(err)} // handle CSRF token errors here - res.status(403) - res.send('form tampered with') + res.status(403); + if (req.xhr || req.headers.accept?.indexOf('json') > -1) { + res.json({ error: 'CSRF token validation failed. Please refresh the page and try again.' }); + } else { + res.send('CSRF token validation failed. Please refresh the page and try again.'); + } }); // Serve the configuration. @@ -94,6 +103,7 @@ module.exports = function(config, options) { const response = { apps, newFeaturesInLatestVersion, + agent: config.agent, }; //Based on advice from Doug Wilson here: @@ -181,6 +191,857 @@ module.exports = function(config, options) { res.send({ success: false, error: 'Something went wrong.' }); }); + // In-memory conversation storage (consider using Redis in future) + const conversations = new Map(); + + // Agent API endpoint for handling AI requests - scoped to specific app + app.post('/apps/:appId/agent', async (req, res) => { + try { + const { message, modelName, conversationId, permissions } = req.body; + const { appId } = req.params; + + if (!message || typeof message !== 'string' || message.trim() === '') { + return res.status(400).json({ error: 'Message is required' }); + } + + if (!modelName || typeof modelName !== 'string') { + return res.status(400).json({ error: 'Model name is required' }); + } + + if (!appId || typeof appId !== 'string') { + return res.status(400).json({ error: 'App ID is required' }); + } + + // Check if agent configuration exists + if (!config.agent || !config.agent.models || !Array.isArray(config.agent.models)) { + return res.status(400).json({ error: 'No agent configuration found' }); + } + + // Find the app in the configuration + const app = config.apps.find(app => (app.appNameForURL || app.appName) === appId); + if (!app) { + return res.status(404).json({ error: `App "${appId}" not found` }); + } + + // Find the requested model + const modelConfig = config.agent.models.find(model => model.name === modelName); + if (!modelConfig) { + return res.status(400).json({ error: `Model "${modelName}" not found in configuration` }); + } + + // Validate model configuration + const { provider, model, apiKey } = modelConfig; + if (!provider || !model || !apiKey) { + return res.status(400).json({ error: 'Model configuration is incomplete' }); + } + + if (apiKey === 'xxxxx' || apiKey.includes('xxx')) { + return res.status(400).json({ error: 'Please replace the placeholder API key with your actual API key' }); + } + + // Only support OpenAI for now + if (provider.toLowerCase() !== 'openai') { + return res.status(400).json({ error: `Provider "${provider}" is not supported yet` }); + } + + // Get or create conversation history + const conversationKey = `${appId}_${conversationId || 'default'}`; + if (!conversations.has(conversationKey)) { + conversations.set(conversationKey, []); + } + + const conversationHistory = conversations.get(conversationKey); + + // Array to track database operations for this request + const operationLog = []; + + // Make request to OpenAI API with app context and conversation history + const response = await makeOpenAIRequest(message, model, apiKey, app, conversationHistory, operationLog, permissions); + + // Update conversation history with user message and AI response + conversationHistory.push( + { role: 'user', content: message }, + { role: 'assistant', content: response || 'Operation completed successfully.' } + ); + + // Keep conversation history to a reasonable size (last 20 messages) + if (conversationHistory.length > 20) { + conversationHistory.splice(0, conversationHistory.length - 20); + } + + // Generate or use provided conversation ID + const finalConversationId = conversationId || `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + + res.json({ + response, + conversationId: finalConversationId, + debug: { + timestamp: new Date().toISOString(), + appId: app.appId, + modelUsed: model, + operations: operationLog + } + }); + + } catch (error) { + // Return the full error message to help with debugging + const errorMessage = error.message || 'Provider error'; + res.status(500).json({ error: `Error: ${errorMessage}` }); + } + }); + + /** + * Database function tools for the AI agent + */ + const databaseTools = [ + { + type: 'function', + function: { + name: 'queryClass', + description: 'Query a Parse class/table to retrieve objects. Use this to fetch data from the database.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class to query' + }, + where: { + type: 'object', + description: 'Query constraints as a JSON object (e.g., {"name": "John", "age": {"$gte": 18}})' + }, + limit: { + type: 'number', + description: 'Maximum number of results to return (default 100, max 1000)' + }, + skip: { + type: 'number', + description: 'Number of results to skip for pagination' + }, + order: { + type: 'string', + description: 'Field to order by (prefix with \'-\' for descending, e.g., \'-createdAt\')' + }, + include: { + type: 'array', + items: { type: 'string' }, + description: 'Array of pointer fields to include/populate' + }, + select: { + type: 'array', + items: { type: 'string' }, + description: 'Array of fields to select (if not provided, all fields are returned)' + } + }, + required: ['className'] + } + } + }, + { + type: 'function', + function: { + name: 'createObject', + description: 'Create a new object in a Parse class/table. IMPORTANT: This is a write operation that requires explicit user confirmation before execution. You must ask the user to confirm before calling this function. You MUST provide the objectData parameter with the actual field values to be saved in the object.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class to create an object in' + }, + objectData: { + type: 'object', + description: 'REQUIRED: The object fields and values for the new object as a JSON object. Example: {\'model\': \'Honda Civic\', \'year\': 2023, \'brand\': \'Honda\'}. This parameter is mandatory and cannot be empty.', + additionalProperties: true + }, + confirmed: { + type: 'boolean', + description: 'Must be true to indicate user has explicitly confirmed this write operation', + default: false + } + }, + required: ['className', 'objectData', 'confirmed'] + } + } + }, + { + type: 'function', + function: { + name: 'updateObject', + description: 'Update an existing object in a Parse class/table. IMPORTANT: This is a write operation that requires explicit user confirmation before execution. You must ask the user to confirm before calling this function.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class containing the object' + }, + objectId: { + type: 'string', + description: 'The objectId of the object to update' + }, + objectData: { + type: 'object', + description: 'The fields to update as a JSON object' + }, + confirmed: { + type: 'boolean', + description: 'Must be true to indicate user has explicitly confirmed this write operation', + default: false + } + }, + required: ['className', 'objectId', 'objectData', 'confirmed'] + } + } + }, + { + type: 'function', + function: { + name: 'deleteObject', + description: 'Delete a SINGLE OBJECT/ROW from a Parse class/table using its objectId. Use this when you want to delete one specific record/object, not the entire class. IMPORTANT: This is a destructive write operation that requires explicit user confirmation before execution. You must ask the user to confirm before calling this function.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class containing the object' + }, + objectId: { + type: 'string', + description: 'The objectId of the specific object/record to delete' + }, + confirmed: { + type: 'boolean', + description: 'Must be true to indicate user has explicitly confirmed this destructive operation', + default: false + } + }, + required: ['className', 'objectId', 'confirmed'] + } + } + }, + { + type: 'function', + function: { + name: 'getSchema', + description: 'Get the schema information for Parse classes. Use this to understand the structure of classes/tables.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class to get schema for (optional - if not provided, returns all schemas)' + } + } + } + } + }, + { + type: 'function', + function: { + name: 'countObjects', + description: 'Count objects in a Parse class/table that match given constraints.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class to count objects in' + }, + where: { + type: 'object', + description: 'Query constraints as a JSON object (optional)' + } + }, + required: ['className'] + } + } + }, + { + type: 'function', + function: { + name: 'createClass', + description: 'Create a new Parse class/table with specified fields. This creates the class structure without any objects.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class to create' + }, + fields: { + type: 'object', + description: 'Fields to define for the class as a JSON object where keys are field names and values are field types (e.g., {"name": "String", "age": "Number", "email": "String"})' + }, + confirmed: { + type: 'boolean', + description: 'Must be true to indicate user has explicitly confirmed this operation', + default: false + } + }, + required: ['className', 'confirmed'] + } + } + }, + { + type: 'function', + function: { + name: 'deleteClass', + description: 'Delete an ENTIRE Parse class/table (the class itself) and ALL its data. Use this when the user wants to delete/remove the entire class/table, not individual objects. This completely removes the class schema and all objects within it. IMPORTANT: This is a highly destructive operation that permanently removes the entire class structure and all objects within it. Requires explicit user confirmation before execution.', + parameters: { + type: 'object', + properties: { + className: { + type: 'string', + description: 'The name of the Parse class/table to completely delete/remove' + }, + confirmed: { + type: 'boolean', + description: 'Must be true to indicate user has explicitly confirmed this highly destructive operation', + default: false + } + }, + required: ['className', 'confirmed'] + } + } + } + ]; + + /** + * Execute database function calls + */ + async function executeDatabaseFunction(functionName, args, appContext, operationLog = [], permissions = {}) { + // Check permissions before executing write operations + const writeOperations = ['deleteObject', 'deleteClass', 'updateObject', 'createObject', 'createClass']; + + if (writeOperations.includes(functionName)) { + // Handle both boolean and string values for permissions + const permissionValue = permissions && permissions[functionName]; + const hasPermission = permissionValue === true || permissionValue === 'true'; + + if (!hasPermission) { + throw new Error(`Permission denied: The "${functionName}" operation is currently disabled in the permissions settings. Please enable this permission in the Parse Dashboard Permissions menu if you want to allow this operation.`); + } + } + + // Configure Parse for this app context + Parse.initialize(appContext.appId, undefined, appContext.masterKey); + Parse.serverURL = appContext.serverURL; + Parse.masterKey = appContext.masterKey; + + try { + switch (functionName) { + case 'queryClass': { + const { className, where = {}, limit = 100, skip = 0, order, include = [], select = [] } = args; + const query = new Parse.Query(className); + + // Apply constraints + Object.keys(where).forEach(key => { + const value = where[key]; + if (typeof value === 'object' && value !== null) { + // Handle complex queries like {$gte: 18} + Object.keys(value).forEach(op => { + switch (op) { + case '$gt': query.greaterThan(key, value[op]); break; + case '$gte': query.greaterThanOrEqualTo(key, value[op]); break; + case '$lt': query.lessThan(key, value[op]); break; + case '$lte': query.lessThanOrEqualTo(key, value[op]); break; + case '$ne': query.notEqualTo(key, value[op]); break; + case '$in': query.containedIn(key, value[op]); break; + case '$nin': query.notContainedIn(key, value[op]); break; + case '$exists': + if (value[op]) {query.exists(key);} + else {query.doesNotExist(key);} + break; + case '$regex': query.matches(key, new RegExp(value[op], value.$options || '')); break; + } + }); + } else { + query.equalTo(key, value); + } + }); + + if (limit) {query.limit(Math.min(limit, 1000));} + if (skip) {query.skip(skip);} + if (order) { + if (order.startsWith('-')) { + query.descending(order.substring(1)); + } else { + query.ascending(order); + } + } + if (include.length > 0) {query.include(include);} + if (select.length > 0) {query.select(select);} + + const results = await query.find({ useMasterKey: true }); + const resultData = results.map(obj => obj.toJSON()); + const operationSummary = { + operation: 'queryClass', + className, + resultCount: results.length, + timestamp: new Date().toISOString() + }; + + operationLog.push(operationSummary); + return resultData; + } + + case 'createObject': { + const { className, objectData, confirmed } = args; + + // Validate required parameters + if (!objectData || typeof objectData !== 'object' || Object.keys(objectData).length === 0) { + throw new Error('Missing or empty \'objectData\' parameter. To create an object, you must provide the objectData fields and values as a JSON object. For example: {\'model\': \'Honda Civic\', \'year\': 2023, \'brand\': \'Honda\'}'); + } + + // Require explicit confirmation for write operations + if (!confirmed) { + throw new Error(`Creating objects requires user confirmation. The AI should ask for permission before creating objects in the ${className} class.`); + } + + const ParseObject = Parse.Object.extend(className); + const object = new ParseObject(); + + Object.keys(objectData).forEach(key => { + object.set(key, objectData[key]); + }); + + const result = await object.save(null, { useMasterKey: true }); + const resultData = result.toJSON(); + + return resultData; + } + + case 'updateObject': { + const { className, objectId, objectData, confirmed } = args; + + // Require explicit confirmation for write operations + if (!confirmed) { + throw new Error(`Updating objects requires user confirmation. The AI should ask for permission before updating object ${objectId} in the ${className} class.`); + } + + const query = new Parse.Query(className); + const object = await query.get(objectId, { useMasterKey: true }); + + Object.keys(objectData).forEach(key => { + object.set(key, objectData[key]); + }); + + const result = await object.save(null, { useMasterKey: true }); + const resultData = result.toJSON(); + + return resultData; + } + + case 'deleteObject': { + const { className, objectId, confirmed } = args; + + // Require explicit confirmation for destructive operations + if (!confirmed) { + throw new Error(`Deleting objects requires user confirmation. The AI should ask for permission before permanently deleting object ${objectId} from the ${className} class.`); + } + + const query = new Parse.Query(className); + const object = await query.get(objectId, { useMasterKey: true }); + + await object.destroy({ useMasterKey: true }); + + const result = { success: true, objectId }; + return result; + } + + case 'getSchema': { + const { className } = args; + let result; + if (className) { + result = await new Parse.Schema(className).get({ useMasterKey: true }); + } else { + result = await Parse.Schema.all({ useMasterKey: true }); + } + return result; + } + + case 'countObjects': { + const { className, where = {} } = args; + const query = new Parse.Query(className); + + Object.keys(where).forEach(key => { + const value = where[key]; + if (typeof value === 'object' && value !== null) { + Object.keys(value).forEach(op => { + switch (op) { + case '$gt': query.greaterThan(key, value[op]); break; + case '$gte': query.greaterThanOrEqualTo(key, value[op]); break; + case '$lt': query.lessThan(key, value[op]); break; + case '$lte': query.lessThanOrEqualTo(key, value[op]); break; + case '$ne': query.notEqualTo(key, value[op]); break; + case '$in': query.containedIn(key, value[op]); break; + case '$nin': query.notContainedIn(key, value[op]); break; + case '$exists': + if (value[op]) {query.exists(key);} + else {query.doesNotExist(key);} + break; + } + }); + } else { + query.equalTo(key, value); + } + }); + + const count = await query.count({ useMasterKey: true }); + + const result = { count }; + return result; + } + + case 'createClass': { + const { className, fields = {}, confirmed } = args; + + // Require explicit confirmation for class creation + if (!confirmed) { + throw new Error(`Creating classes requires user confirmation. The AI should ask for permission before creating the ${className} class.`); + } + + const schema = new Parse.Schema(className); + + // Add fields to the schema + Object.keys(fields).forEach(fieldName => { + const fieldType = fields[fieldName]; + switch (fieldType.toLowerCase()) { + case 'string': + schema.addString(fieldName); + break; + case 'number': + schema.addNumber(fieldName); + break; + case 'boolean': + schema.addBoolean(fieldName); + break; + case 'date': + schema.addDate(fieldName); + break; + case 'array': + schema.addArray(fieldName); + break; + case 'object': + schema.addObject(fieldName); + break; + case 'geopoint': + schema.addGeoPoint(fieldName); + break; + case 'file': + schema.addFile(fieldName); + break; + default: + // For pointer fields or unknown types, try to add as string + schema.addString(fieldName); + break; + } + }); + + const result = await schema.save({ useMasterKey: true }); + + const resultData = { success: true, className, schema: result }; + return resultData; + } + + case 'deleteClass': { + const { className, confirmed } = args; + + // Require explicit confirmation for class deletion - this is highly destructive + if (!confirmed) { + throw new Error(`Deleting classes requires user confirmation. The AI should ask for permission before permanently deleting the ${className} class and ALL its data.`); + } + + // Check if the class exists first + try { + await new Parse.Schema(className).get({ useMasterKey: true }); + } catch (error) { + if (error.code === 103) { + throw new Error(`Class "${className}" does not exist.`); + } + throw error; + } + + // Delete the class and all its data + const schema = new Parse.Schema(className); + + try { + // First purge all objects from the class + await schema.purge({ useMasterKey: true }); + + // Then delete the class schema itself + await schema.delete({ useMasterKey: true }); + + const resultData = { success: true, className, message: `Class "${className}" and all its data have been permanently deleted.` }; + return resultData; + } catch (deleteError) { + throw new Error(`Failed to delete class "${className}": ${deleteError.message}`); + } + } + + default: + throw new Error(`Unknown function: ${functionName}`); + } + } catch (error) { + console.error('Database operation error:', { + functionName, + args, + appId: appContext.appId, + serverURL: appContext.serverURL, + error: error.message, + stack: error.stack + }); + throw new Error(`Database operation failed: ${error.message}`); + } + } + + /** + * Make a request to OpenAI API + */ + async function makeOpenAIRequest(userMessage, model, apiKey, appContext = null, conversationHistory = [], operationLog = [], permissions = {}) { + const fetch = (await import('node-fetch')).default; + + const url = 'https://api.openai.com/v1/chat/completions'; + + const appInfo = appContext ? + `\n\nContext: You are currently helping with the Parse Server app "${appContext.appName}" (ID: ${appContext.appId}) at ${appContext.serverURL}.` : + ''; + + // Build messages array starting with system message + const messages = [ + { + role: 'system', + content: `You are an AI assistant integrated into Parse Dashboard, a data management interface for Parse Server applications. + +Your role is to help users with: +- Database queries and data operations using the Parse JS SDK +- Understanding Parse Server concepts and best practices +- Troubleshooting common issues +- Best practices for data modeling +- Cloud Code and server configuration guidance + +You have access to database function tools that allow you to: +- Query classes/tables to retrieve objects (read-only, no confirmation needed) +- Create new objects in classes (REQUIRES USER CONFIRMATION) +- Update existing objects (REQUIRES USER CONFIRMATION) +- Delete INDIVIDUAL objects by objectId (REQUIRES USER CONFIRMATION) +- Delete ENTIRE classes/tables and all their data (REQUIRES USER CONFIRMATION) +- Get schema information for classes (read-only, no confirmation needed) +- Count objects that match certain criteria (read-only, no confirmation needed) +- Create new empty classes/tables (REQUIRES USER CONFIRMATION) + +IMPORTANT: Choose the correct function based on what the user wants to delete: +- Use 'deleteObject' when deleting a specific object/record by its objectId +- Use 'deleteClass' when deleting an entire class/table (the class itself and all its data) + +CRITICAL SECURITY RULE FOR WRITE OPERATIONS: +- ANY write operation (create, update, delete) MUST have explicit user confirmation through conversation +- When a user requests a write operation, explain what you will do and ask for confirmation +- Only call the write operation functions with confirmed=true after the user has explicitly agreed +- If a user says "Create a new class", treat this as confirmation to create objects in that class +- You CANNOT perform write operations without the user's knowledge and consent +- Read operations (query, schema, count) can be performed immediately without confirmation + +Confirmation Pattern: +1. User requests operation (e.g., "Create a new class called Products") +2. You ask: "I'll create a new object in the Products class. Should I proceed?" +3. User confirms: "Yes" / "Go ahead" / "Do it" +4. You call the function with confirmed=true + +When working with the database: +- Read operations (query, getSchema, count) can be performed immediately +- Write operations require the pattern: 1) Explain what you'll do, 2) Ask for confirmation, 3) Only then execute if confirmed +- Always use the provided database functions instead of writing code +- Class names are case-sensitive +- Use proper Parse query syntax for complex queries +- Handle objectId fields correctly +- Be mindful of data types (Date, Pointer, etc.) +- Always consider security and use appropriate query constraints +- Provide clear explanations of what database operations you're performing +- If any database function returns an error, you MUST include the full error message in your response to the user. Never hide error details or give vague responses like "there was an issue" - always show the specific error message. +- IMPORTANT: When creating objects, you MUST provide the 'objectData' parameter with actual field values. Never call createObject with only className and confirmed - always include the objectData object with the fields and values to be saved. +- IMPORTANT: When updating objects, you MUST provide the 'objectData' parameter with the fields you want to update. Include the objectData object with field names and new values. + +CRITICAL RULE FOR createObject FUNCTION: +- The createObject function REQUIRES THREE parameters: className, objectData, and confirmed +- The 'objectData' parameter MUST contain the actual field values as a JSON object +- NEVER call createObject with only className and confirmed - this will fail +- Example: createObject({className: 'TestCars', objectData: {model: 'Honda Civic', year: 2023, brand: 'Honda'}, confirmed: true}) +- The objectData object should contain all the fields and their values that you want to save + +When responding: +- Be concise and helpful +- Provide practical examples when relevant +- Ask clarifying questions if the user's request is unclear +- Focus on Parse-specific solutions and recommendations +- If you perform database operations, explain what you did and show the results +- For write operations, always explain the impact and ask for explicit confirmation +- Format your responses using Markdown for better readability: + * Use **bold** for important information + * Use *italic* for emphasis + * Use \`code\` for field names, class names, and values + * Use numbered lists for step-by-step instructions + * Use bullet points for listing items + * Use tables when showing structured data + * Use code blocks with language specification for code examples + * Use headers (##, ###) to organize longer responses + * When listing database classes, format as a numbered list with descriptions + * Use tables for structured data comparison + +You have direct access to the Parse database through function calls, so you can query actual data and provide real-time information.${appInfo}` + } + ]; + + // Add conversation history if it exists + if (conversationHistory && conversationHistory.length > 0) { + // Filter out any messages with null or undefined content to prevent API errors + const validHistory = conversationHistory.filter(msg => + msg && typeof msg === 'object' && msg.role && + (msg.content !== null && msg.content !== undefined && msg.content !== '') + ); + messages.push(...validHistory); + } + + // Add the current user message + messages.push({ + role: 'user', + content: userMessage + }); + + const requestBody = { + model: model, + messages: messages, + temperature: 0.7, + max_tokens: 2000, + tools: databaseTools, + tool_choice: 'auto', + stream: false + }; + + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify(requestBody) + }); + + if (!response.ok) { + if (response.status === 401) { + throw new Error('Invalid API key. Please check your OpenAI API key configuration.'); + } else if (response.status === 429) { + throw new Error('Rate limit exceeded. Please try again in a moment.'); + } else if (response.status === 403) { + throw new Error('Access forbidden. Please check your API key permissions.'); + } else if (response.status >= 500) { + throw new Error('OpenAI service is temporarily unavailable. Please try again later.'); + } + + const errorData = await response.json().catch(() => ({})); + const errorMessage = (errorData && typeof errorData === 'object' && 'error' in errorData && errorData.error && typeof errorData.error === 'object' && 'message' in errorData.error) + ? errorData.error.message + : `HTTP ${response.status}: ${response.statusText}`; + throw new Error(`OpenAI API error: ${errorMessage}`); + } + + const data = await response.json(); + + if (!data || typeof data !== 'object' || !('choices' in data) || !Array.isArray(data.choices) || data.choices.length === 0) { + throw new Error('No response received from OpenAI API'); + } + + const choice = data.choices[0]; + const responseMessage = choice.message; + + // Handle function calls + if (responseMessage.tool_calls && responseMessage.tool_calls.length > 0) { + const toolCalls = responseMessage.tool_calls; + const toolResponses = []; + + for (const toolCall of toolCalls) { + if (toolCall.type === 'function') { + try { + const functionName = toolCall.function.name; + const functionArgs = JSON.parse(toolCall.function.arguments); + + console.log('Executing database function:', { + functionName, + args: functionArgs, + appId: appContext.appId, + serverURL: appContext.serverURL, + timestamp: new Date().toISOString() + }); + + // Execute the database function + const result = await executeDatabaseFunction(functionName, functionArgs, appContext, operationLog, permissions); + + toolResponses.push({ + tool_call_id: toolCall.id, + role: 'tool', + content: result ? JSON.stringify(result) : JSON.stringify({ success: true }) + }); + } catch (error) { + toolResponses.push({ + tool_call_id: toolCall.id, + role: 'tool', + content: JSON.stringify({ error: error.message || 'Unknown error occurred' }) + }); + } + } + } + + // Make a second request with the tool responses + const followUpMessages = [ + ...messages, + responseMessage, + ...toolResponses + ]; + + const followUpRequestBody = { + model: model, + messages: followUpMessages, + temperature: 0.7, + max_tokens: 2000, + tools: databaseTools, + tool_choice: 'auto', + stream: false + }; + + const followUpResponse = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify(followUpRequestBody) + }); + + if (!followUpResponse.ok) { + throw new Error(`Follow-up request failed: ${followUpResponse.statusText}`); + } + + const followUpData = await followUpResponse.json(); + + if (!followUpData || typeof followUpData !== 'object' || !('choices' in followUpData) || !Array.isArray(followUpData.choices) || followUpData.choices.length === 0) { + throw new Error('No follow-up response received from OpenAI API'); + } + + const followUpContent = followUpData.choices[0].message.content; + if (!followUpContent) { + console.warn('OpenAI returned null content in follow-up response, using fallback message'); + } + return followUpContent || 'Done.'; + } + + const content = responseMessage.content; + if (!content) { + console.warn('OpenAI returned null content in initial response, using fallback message'); + } + return content || 'Done.'; + } + // Serve the app icons. Uses the optional `iconsFolder` parameter as // directory name, that was setup in the config file. // We are explicitly not using `__dirpath` here because one may be diff --git a/README.md b/README.md index b365badd7a..0b3250d33d 100644 --- a/README.md +++ b/README.md @@ -74,10 +74,14 @@ Parse Dashboard is a standalone dashboard for managing your [Parse Server](https - [Panel Item](#panel-item) - [Prefetching](#prefetching) - [Freeze Columns](#freeze-columns) - - [Browse as User](#browse-as-user) - - [Change Pointer Key](#change-pointer-key) - - [Limitations](#limitations) - - [CSV Export](#csv-export) + - [Browse as User](#browse-as-user) + - [Change Pointer Key](#change-pointer-key) + - [Limitations](#limitations) + - [CSV Export](#csv-export) + - [AI Agent](#ai-agent) + - [Configuration](#configuration) + - [Providers](#providers) + - [OpenAI](#openai) - [Views](#views) - [Data Sources](#data-sources) - [Aggregation Pipeline](#aggregation-pipeline) @@ -1231,7 +1235,7 @@ Prefetching is particularly useful when navigating through lists of objects. To Right-click on a table column header to freeze columns from the left up to the clicked column in the data browser. When scrolling horizontally, the frozen columns remain visible while the other columns scroll underneath. -## Browse as User +### Browse as User ▶️ *Core > Browser > Browse* @@ -1239,20 +1243,21 @@ This feature allows you to use the data browser as another user, respecting that > ⚠️ Logging in as another user will trigger the same Cloud Triggers as if the user logged in themselves using any other login method. Logging in as another user requires to enter that user's password. -## Change Pointer Key +### Change Pointer Key ▶️ *Core > Browser > Edit > Change pointer key* This feature allows you to change how a pointer is represented in the browser. By default, a pointer is represented by the `objectId` of the linked object. You can change this to any other column of the object class. For example, if class `Installation` has a field that contains a pointer to class `User`, the pointer will show the `objectId` of the user by default. You can change this to display the field `email` of the user, so that a pointer displays the user's email address instead. -### Limitations +#### Limitations - This does not work for an array of pointers; the pointer will always display the `objectId`. - System columns like `createdAt`, `updatedAt`, `ACL` cannot be set as pointer key. - This feature uses browser storage; switching to a different browser resets the pointer key to `objectId`. > ⚠️ For each custom pointer key in each row, a server request is triggered to resolve the custom pointer key. For example, if the browser shows a class with 50 rows and each row contains 3 custom pointer keys, a total of 150 separate server requests are triggered. -## CSV Export + +### CSV Export ▶️ *Core > Browser > Export* @@ -1260,6 +1265,83 @@ This feature will take either selected rows or all rows of an individual class a > ⚠️ There is currently a 10,000 row limit when exporting all data. If more than 10,000 rows are present in the class, the CSV file will only contain 10,000 rows. +## AI Agent + +The Parse Dashboard includes an AI agent that can help manage your Parse Server data through natural language commands. The agent can perform operations like creating classes, adding data, querying records, and more. + +> [!Caution] +> The AI agent has full access to your database using the master key. It can read, modify, and delete any data. This feature is highly recommended for development environments only. Always back up important data before using the AI agent. + +### Configuration + +To configure the AI agent for your dashboard, you need to add the `agent` configuration to your Parse Dashboard config: + +```json +{ + "apps": [ + // ... + ], + "agent": { + "models": [ + { + "name": "ChatGPT 4.1", + "provider": "openai", + "model": "gpt-4.1", + "apiKey": "YOUR_OPENAI_API_KEY" + }, + ] + } +} +``` + +| Parameter | Type | Required | Description | +|-----------------------------|--------|----------|--------------------------------------------------------------------------------| +| `agent` | Object | Yes | The AI agent configuration object. | +| `agent.models` | Array | Yes | Array of AI model configurations available to the agent. | +| `agent.models[*].name` | String | Yes | The display name for the model (e.g., `ChatGPT 4.1`). | +| `agent.models[*].provider` | String | Yes | The AI provider identifier (e.g., "openai"). | +| `agent.models[*].model` | String | Yes | The specific model name from the provider (e.g., `gpt-4.1`). | +| `agent.models[*].apiKey` | String | Yes | The API key for authenticating with the AI provider. | + +The agent will use the configured models to process natural language commands and perform database operations using the master key from your app configuration. + +### Providers + +> [!Note] +> Currently, only OpenAI models are supported. Support for additional providers may be added in future releases. + +#### OpenAI + +To get an OpenAI API key for use with the AI agent: + +1. **Create an OpenAI account**: Visit [platform.openai.com](https://platform.openai.com) and sign up for an account if you don't already have one. + +2. **Access the API section**: Once logged in, navigate to the API section of your OpenAI dashboard. + +3. **Create a new project**: + - Go to the "Projects" section + - Click "Create project" + - Name your project "Parse-Dashboard" (or any descriptive name) + - Complete the project setup + +4. **Configure model access**: + - In your project, navigate to "Limits > Model Usage" + - Select the AI models you want to use (e.g., `gpt-4`, `gpt-3.5-turbo`) + - These model names will be used as the `agent.models[*].model` parameter in your dashboard configuration + +5. **Generate an API key**: + - Go to the "API Keys" page in your project settings + - Click "Create new secret key" + - Give your key a descriptive name (e.g., "Parse Dashboard Agent") + - Copy the generated API key immediately (you won't be able to see it again) + +6. **Set up billing**: Make sure you have a valid payment method added to your OpenAI account, as API usage incurs charges. + +7. **Configure the dashboard**: Add the API key to your Parse Dashboard configuration as shown in the example above. + +> [!Important] +> Keep your API key secure and never commit it to version control. Consider using environment variables or secure configuration management for production deployments. + ## Views ▶️ *Core > Views* diff --git a/package-lock.json b/package-lock.json index a4c0076ff9..753e3ab932 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "immutable-devtools": "0.1.5", "inquirer": "12.6.3", "js-beautify": "1.15.4", + "node-fetch": "3.3.2", "otpauth": "8.0.3", "package-json": "7.0.0", "parse": "3.5.1", @@ -9468,6 +9469,25 @@ "node-fetch": "2.6.7" } }, + "node_modules/cross-fetch/node_modules/node-fetch": { + "version": "2.6.7", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", + "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -11731,6 +11751,28 @@ "pend": "~1.2.0" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/fetch-node-website": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/fetch-node-website/-/fetch-node-website-9.0.1.tgz", @@ -12243,6 +12285,17 @@ "node": ">= 14.17" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -16207,6 +16260,25 @@ "license": "MIT", "optional": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -16223,22 +16295,28 @@ } }, "node_modules/node-fetch": { - "version": "2.6.7", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", - "integrity": "sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", "dependencies": { - "whatwg-url": "^5.0.0" + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-fetch/node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" } }, "node_modules/node-int64": { @@ -23541,7 +23619,7 @@ "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o=" + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" }, "node_modules/traverse": { "version": "0.6.8", @@ -24289,10 +24367,18 @@ "defaults": "^1.0.3" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=" + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, "node_modules/webpack": { "version": "5.99.9", @@ -24455,7 +24541,7 @@ "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha1-lmRU6HZUYuN2RNNib2dCzotwll0=", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" diff --git a/package.json b/package.json index ad10643454..d7a96c185d 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "immutable-devtools": "0.1.5", "inquirer": "12.6.3", "js-beautify": "1.15.4", + "node-fetch": "3.3.2", "otpauth": "8.0.3", "package-json": "7.0.0", "parse": "3.5.1", diff --git a/src/components/EmptyState/EmptyState.react.js b/src/components/EmptyState/EmptyState.react.js index e791f82867..b2178388db 100644 --- a/src/components/EmptyState/EmptyState.react.js +++ b/src/components/EmptyState/EmptyState.react.js @@ -41,18 +41,27 @@ const EmptyState = ({ action = () => {}, secondaryCta = '', secondaryAction = () => {}, -}) => ( -
-
- + customContent = null, + useFlexLayout = false, +}) => { + const containerClass = useFlexLayout ? styles.flexContainer : baseStyles.center; + + return ( +
+
+
+ +
+
{title}
+
{description}
+ {ctaButton(cta, action)} + {secondaryCta && ' '} + {ctaButton(secondaryCta, secondaryAction)} +
+ {customContent &&
{customContent}
}
-
{title}
-
{description}
- {ctaButton(cta, action)} - {secondaryCta && ' '} - {ctaButton(secondaryCta, secondaryAction)} -
-); + ); +}; EmptyState.propTypes = { icon: PropTypes.string.describe('The name of the large icon that appears in the empty state.'), @@ -72,6 +81,8 @@ EmptyState.propTypes = { secondaryAction: PropTypes.oneOfType([PropTypes.string, PropTypes.func]).describe( 'An href link or a click handler that is forwarded to the secondary CTA button.' ), + customContent: PropTypes.node.describe('Custom content to render below the empty state.'), + useFlexLayout: PropTypes.bool.describe('Whether to use flex layout instead of absolute positioning.'), }; export default EmptyState; diff --git a/src/components/EmptyState/EmptyState.scss b/src/components/EmptyState/EmptyState.scss index 8fddab4268..122a52213c 100644 --- a/src/components/EmptyState/EmptyState.scss +++ b/src/components/EmptyState/EmptyState.scss @@ -7,6 +7,27 @@ */ @import 'stylesheets/globals.scss'; +.flexContainer { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 32px; +} + +.content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + max-width: 600px; +} + +.customContent { + width: 100%; + max-width: 600px; +} + .title { font-size: 46px; font-weight: 100; diff --git a/src/components/Sidebar/Sidebar.react.js b/src/components/Sidebar/Sidebar.react.js index 69dcecd78a..d504f2934a 100644 --- a/src/components/Sidebar/Sidebar.react.js +++ b/src/components/Sidebar/Sidebar.react.js @@ -82,7 +82,7 @@ const Sidebar = ({ } return (
- {subsections.map(({ name, link }) => { + {subsections.map(({ name, link, icon }) => { const active = subsection === name; return ( {active ? children : null} diff --git a/src/components/Sidebar/Sidebar.scss b/src/components/Sidebar/Sidebar.scss index a051af435f..c73033aa73 100644 --- a/src/components/Sidebar/Sidebar.scss +++ b/src/components/Sidebar/Sidebar.scss @@ -271,17 +271,26 @@ $footerHeight: 36px; font-size: 16px; font-weight: 700; color: white; + display: flex; + align-items: center; } a.subitem { color: #8fb9cf; font-weight: 400; - display: inline-block; + display: flex; + align-items: center; width: 100%; &:hover { color: white; } + + svg { + &:hover { + fill: white; + } + } } .action { diff --git a/src/components/Sidebar/SidebarSubItem.react.js b/src/components/Sidebar/SidebarSubItem.react.js index 3017466b60..214889a5ec 100644 --- a/src/components/Sidebar/SidebarSubItem.react.js +++ b/src/components/Sidebar/SidebarSubItem.react.js @@ -8,13 +8,15 @@ import { Link } from 'react-router-dom'; import React from 'react'; import styles from 'components/Sidebar/Sidebar.scss'; +import Icon from 'components/Icon/Icon.react'; -const SidebarSubItem = ({ active, name, action, link, children }) => { +const SidebarSubItem = ({ active, name, action, link, children, icon }) => { if (active) { return (
{name} + {icon && } {action ? action.renderButton() : null}
{children}
@@ -26,6 +28,7 @@ const SidebarSubItem = ({ active, name, action, link, children }) => {
{name} + {icon && }
); diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index b1e8a800bd..58e0552828 100644 --- a/src/dashboard/Dashboard.js +++ b/src/dashboard/Dashboard.js @@ -7,6 +7,7 @@ */ import AccountOverview from './Account/AccountOverview.react'; import AccountView from './AccountView.react'; +import Agent from './Data/Agent/Agent.react'; import AnalyticsOverview from './Analytics/Overview/Overview.react'; import ApiConsole from './Data/ApiConsole/ApiConsole.react'; import AppData from './AppData.react'; @@ -112,6 +113,7 @@ export default class Dashboard extends React.Component { configLoadingError: '', configLoadingState: AsyncStatus.PROGRESS, newFeaturesInLatestVersion: [], + agentConfig: null, }; setBasePath(props.path); sessionStorage.removeItem('username'); @@ -120,8 +122,8 @@ export default class Dashboard extends React.Component { componentDidMount() { get('/parse-dashboard-config.json') - .then(({ apps, newFeaturesInLatestVersion = [] }) => { - this.setState({ newFeaturesInLatestVersion }); + .then(({ apps, newFeaturesInLatestVersion = [], agent }) => { + this.setState({ newFeaturesInLatestVersion, agentConfig: agent }); const appInfoPromises = apps.map(app => { if (app.serverURL.startsWith('https://api.parse.com/1')) { @@ -273,6 +275,7 @@ export default class Dashboard extends React.Component { } /> } /> } /> + } /> } /> {JobsRoute} diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js index c48e5ac461..f438ca3a75 100644 --- a/src/dashboard/DashboardView.react.js +++ b/src/dashboard/DashboardView.react.js @@ -34,6 +34,7 @@ export default class DashboardView extends React.Component { onRouteChanged() { const path = this.props.location?.pathname ?? window.location.pathname; const route = path.split('apps')[1].split('/')[2]; + if (route !== this.state.route) { this.setState({ route }); } @@ -81,6 +82,12 @@ export default class DashboardView extends React.Component { link: '/views', }); + coreSubsections.push({ + name: 'Agent', + link: '/agent', + icon: 'sparkle-solid', + }); + //webhooks requires removal of heroku link code, then it should work. if ( features.hooks && diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js new file mode 100644 index 0000000000..7f5473f403 --- /dev/null +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -0,0 +1,617 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import BrowserMenu from 'components/BrowserMenu/BrowserMenu.react'; +import DashboardView from 'dashboard/DashboardView.react'; +import EmptyState from 'components/EmptyState/EmptyState.react'; +import Icon from 'components/Icon/Icon.react'; +import Markdown from 'components/Markdown/Markdown.react'; +import MenuItem from 'components/BrowserMenu/MenuItem.react'; +import React from 'react'; +import SidebarAction from 'components/Sidebar/SidebarAction'; +import Toolbar from 'components/Toolbar/Toolbar.react'; +import AgentService from 'lib/AgentService'; +import styles from './Agent.scss'; +import { withRouter } from 'lib/withRouter'; +import { CurrentApp } from 'context/currentApp'; + +@withRouter +class Agent extends DashboardView { + static contextType = CurrentApp; + + constructor(props) { + super(props); + this.section = 'Core'; + this.subsection = 'Agent'; + + this.state = { + messages: [], + inputValue: '', + isLoading: false, + selectedModel: this.getStoredSelectedModel(), + conversationId: null, + permissions: this.getStoredPermissions(), + // Force re-render key + permissionsKey: 0, + }; + + this.browserMenuRef = React.createRef(); + this.chatInputRef = React.createRef(); + this.chatWindowRef = React.createRef(); + this.action = new SidebarAction('Clear Chat', () => this.clearChat()); + } + + getStoredSelectedModel() { + const stored = localStorage.getItem('selectedAgentModel'); + return stored; + } + + getStoredPermissions() { + try { + const stored = localStorage.getItem('agentPermissions'); + return stored ? JSON.parse(stored) : { + deleteObject: false, + deleteClass: false, + updateObject: false, + createObject: false, + createClass: false, + }; + } catch (error) { + console.warn('Failed to parse stored permissions, using defaults:', error); + return { + deleteObject: false, + deleteClass: false, + updateObject: false, + createObject: false, + createClass: false, + }; + } + } + + setPermission = (operation, enabled) => { + this.setState(prevState => { + const newPermissions = { + ...prevState.permissions, + [operation]: enabled + }; + + // Save to localStorage immediately + localStorage.setItem('agentPermissions', JSON.stringify(newPermissions)); + + return { + permissions: newPermissions, + permissionsKey: prevState.permissionsKey + 1 + }; + }); + } + + getStoredChatState() { + try { + const appSlug = this.context ? this.context.slug : null; + if (!appSlug) {return null;} + + const stored = localStorage.getItem(`agentChat_${appSlug}`); + if (!stored) {return null;} + + const parsedState = JSON.parse(stored); + + // Validate the structure + if (!parsedState || typeof parsedState !== 'object') {return null;} + if (!Array.isArray(parsedState.messages)) {return null;} + + // Check if the data is too old (optional: 24 hours expiry) + const ONE_DAY = 24 * 60 * 60 * 1000; + if (parsedState.timestamp && (Date.now() - parsedState.timestamp > ONE_DAY)) { + localStorage.removeItem(`agentChat_${appSlug}`); + return null; + } + + return parsedState; + } catch (error) { + console.warn('Failed to parse stored chat state:', error); + return null; + } + } + + saveChatState() { + try { + const appSlug = this.context ? this.context.slug : null; + if (!appSlug) {return;} + + const chatState = { + messages: this.state.messages, + conversationId: this.state.conversationId, + timestamp: Date.now() + }; + localStorage.setItem(`agentChat_${appSlug}`, JSON.stringify(chatState)); + } catch (error) { + console.warn('Failed to save chat state:', error); + } + } + + componentDidMount() { + // Fix the routing issue by ensuring this.state.route is set to 'agent' + if (this.state.route !== 'agent') { + this.setState({ route: 'agent' }); + } + + this.setDefaultModel(); + + // Load saved chat state after component mounts when context is available + this.loadSavedChatState(); + } + + loadSavedChatState() { + const savedChatState = this.getStoredChatState(); + if (savedChatState && savedChatState.messages && savedChatState.messages.length > 0) { + // Convert timestamp strings back to Date objects + const messagesWithDateTimestamps = savedChatState.messages.map(message => ({ + ...message, + timestamp: new Date(message.timestamp) + })); + + this.setState({ + messages: messagesWithDateTimestamps, + conversationId: savedChatState.conversationId || null, + }); + } + } + + componentWillUnmount() { + // Save chat state when component unmounts (navigation away) + this.saveChatState(); + } + + componentDidUpdate(prevProps, prevState) { + // If agentConfig just became available, set default model + if (!prevProps.agentConfig && this.props.agentConfig) { + this.setDefaultModel(); + } + + // Save chat state when messages change + if (prevState.messages.length !== this.state.messages.length || + prevState.conversationId !== this.state.conversationId) { + this.saveChatState(); + } + + // Auto-scroll to bottom when new messages are added or loading state changes + if (prevState.messages.length !== this.state.messages.length || + prevState.isLoading !== this.state.isLoading) { + // Use requestAnimationFrame and setTimeout to ensure DOM has updated + requestAnimationFrame(() => { + setTimeout(() => this.scrollToBottom(), 50); + }); + } + } + + setDefaultModel() { + // Set default selected model if none is selected and models are available + const { agentConfig } = this.props; + const { selectedModel } = this.state; + const models = agentConfig?.models || []; + + if (!selectedModel && models.length > 0) { + this.setSelectedModel(models[0].name); + } + } + + setSelectedModel(modelName) { + this.setState({ selectedModel: modelName }); + localStorage.setItem('selectedAgentModel', modelName); + } + + scrollToBottom() { + if (this.chatWindowRef.current) { + const element = this.chatWindowRef.current; + element.scrollTop = element.scrollHeight; + + // Force smooth scrolling behavior + element.scrollTo({ + top: element.scrollHeight, + behavior: 'smooth' + }); + } + } + + clearChat() { + this.setState({ + messages: [], + conversationId: null, // Reset conversation to start fresh + }); + + // Clear saved chat state from localStorage + try { + const appSlug = this.context ? this.context.slug : null; + if (appSlug) { + localStorage.removeItem(`agentChat_${appSlug}`); + } + } catch (error) { + console.warn('Failed to clear saved chat state:', error); + } + + // Close the menu by simulating an external click + if (this.browserMenuRef.current) { + this.browserMenuRef.current.setState({ open: false }); + } + } + + handleInputChange = (event) => { + this.setState({ inputValue: event.target.value }); + } + + handleExampleClick = (exampleText) => { + this.setState({ inputValue: exampleText }, () => { + // Auto-submit the example query + const event = { preventDefault: () => {} }; + this.handleSubmit(event); + }); + } + + handleSubmit = async (event) => { + event.preventDefault(); + const { inputValue, selectedModel, messages } = this.state; + const { agentConfig } = this.props; + + if (inputValue.trim() === '') { + return; + } + + // Find the selected model configuration + const models = agentConfig?.models || []; + const modelConfig = models.find(model => model.name === selectedModel) || models[0]; + + if (!modelConfig) { + const errorMessage = { + id: Date.now() + 1, + type: 'agent', + content: 'No AI model is configured. Please check your dashboard configuration.', + timestamp: new Date(), + isError: true, + }; + + this.setState(prevState => ({ + messages: [...prevState.messages, errorMessage], + isLoading: false, + })); + return; + } + + // Add warning message if this is the first message in the conversation + const isFirstMessage = messages.length === 0; + const messagesToAdd = []; + + if (isFirstMessage) { + const warningMessage = { + id: Date.now() - 1, + type: 'warning', + content: 'The AI agent has full access to your database using the master key. It can read, modify, and delete any data. This feature is highly recommended for development environments only. Always back up important data before using the AI agent. Use the permissions menu to restrict operations.', + timestamp: new Date(), + }; + messagesToAdd.push(warningMessage); + } + + // Add user message + const userMessage = { + id: Date.now(), + type: 'user', + content: inputValue.trim(), + timestamp: new Date(), + }; + messagesToAdd.push(userMessage); + + this.setState(prevState => ({ + messages: [...prevState.messages, ...messagesToAdd], + inputValue: '', + isLoading: true, + })); + + try { + // Validate model configuration + AgentService.validateModelConfig(modelConfig); + + // Get app slug from context + const appSlug = this.context ? this.context.slug : null; + if (!appSlug) { + throw new Error('App context not available'); + } + + // Get response from AI service with conversation context + const result = await AgentService.sendMessage( + inputValue.trim(), + modelConfig, + appSlug, + this.state.conversationId, + this.state.permissions + ); + + const aiMessage = { + id: Date.now() + 1, + type: 'agent', + content: result.response, + timestamp: new Date(), + }; + + this.setState(prevState => ({ + messages: [...prevState.messages, aiMessage], + isLoading: false, + conversationId: result.conversationId, // Update conversation ID + })); + + } catch (error) { + console.error('Agent API error:', error); + + let errorContent = `Error: ${error.message}`; + + // Handle specific error types + if (error.message && error.message.includes('Permission Denied')) { + errorContent = 'Error: Permission denied. Please refresh the page and try again.'; + } else if (error.message && error.message.includes('CSRF')) { + errorContent = 'Error: Security token expired. Please refresh the page and try again.'; + } + + const errorMessage = { + id: Date.now() + 1, + type: 'agent', + content: errorContent, + timestamp: new Date(), + isError: true, + }; + + this.setState(prevState => ({ + messages: [...prevState.messages, errorMessage], + isLoading: false, + })); + } + + // Focus the input field after the response + setTimeout(() => { + if (this.chatInputRef.current) { + this.chatInputRef.current.focus(); + } + }, 100); + } + + renderToolbar() { + const { agentConfig } = this.props; + const { selectedModel, permissions, permissionsKey } = this.state; + const models = agentConfig?.models || []; + + const permissionOperations = [ + { key: 'deleteObject', label: 'Delete Objects' }, + { key: 'deleteClass', label: 'Delete Classes' }, + { key: 'updateObject', label: 'Update Objects' }, + { key: 'createObject', label: 'Create Objects' }, + { key: 'createClass', label: 'Create Classes' }, + ]; + + return ( + + {models.length > 0 && ( + {}} + > + {models.map((model, index) => ( + + {selectedModel === model.name && ( + + )} + {model.name} + + } + onClick={() => this.setSelectedModel(model.name)} + /> + ))} + + )} + {}} + > + {permissionOperations.map((operation) => ( + + {permissions[operation.key] && ( + + )} + {operation.label} + + } + onClick={() => { + this.setPermission(operation.key, !permissions[operation.key]); + }} + /> + ))} + + {}} + > + this.clearChat()} /> + + + ); + } + + formatMessageContent(content) { + // Use the existing Markdown component to render the content + return ; + } + + renderMessages() { + const { messages, isLoading } = this.state; + + if (messages.length === 0) { + return null; // Empty state is now handled as overlay + } + + return ( +
+ {messages.map((message) => ( +
+ {message.type === 'warning' ? ( + <> + +
+ {message.content} +
+ + ) : ( + <> +
+ {message.type === 'agent' ? this.formatMessageContent(message.content) : message.content} +
+
+ {message.timestamp instanceof Date ? + message.timestamp.toLocaleTimeString() : + new Date(message.timestamp).toLocaleTimeString() + } +
+ + )} +
+ ))} + {isLoading && ( +
+
+
+ + + +
+
+
+ )} +
+ ); + } + + renderChatInput() { + const { inputValue, isLoading } = this.state; + + return ( +
+
+ + +
+
+ ); + } + + renderContent() { + const { messages } = this.state; + const { agentConfig } = this.props; + const models = agentConfig?.models || []; + + // Check if agent configuration is missing or no models are configured + const hasNoAgentConfig = !agentConfig; + const hasNoModels = models.length === 0; + + return ( +
+ {this.renderToolbar()} +
+
+ {this.renderMessages()} +
+ {!hasNoAgentConfig && !hasNoModels && this.renderChatInput()} +
+ {messages.length === 0 && ( +
+ {hasNoAgentConfig || hasNoModels ? ( + + ) : ( + +

Try asking:

+
+ + + +
+
+ } + /> + )} +
+ )} +
+ ); + } +} + +export default Agent; diff --git a/src/dashboard/Data/Agent/Agent.scss b/src/dashboard/Data/Agent/Agent.scss new file mode 100644 index 0000000000..fe55407c3c --- /dev/null +++ b/src/dashboard/Data/Agent/Agent.scss @@ -0,0 +1,373 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +@import 'stylesheets/globals.scss'; + +.agentContainer { + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.chatContainer { + display: flex; + flex-direction: column; + flex: 1; + height: calc(100vh - 60px); /* Account for toolbar */ + max-height: calc(100vh - 60px); /* Prevent expansion */ + overflow: hidden; +} + +.chatWindow { + flex: 1; + overflow-y: auto; + padding: 20px; + padding-top: 116px; /* Add top padding to account for the 96px fixed toolbar + some extra spacing */ + padding-bottom: 80px; /* Add bottom padding to account for the fixed chat form */ + background-color: #f8f9fa; + scroll-behavior: smooth; + height: calc(100vh - 60px); /* Explicit height constraint */ + max-height: calc(100vh - 60px); /* Prevent expansion beyond viewport */ + min-height: 0; /* Allow flex item to shrink below content size */ +} + +.emptyStateOverlay { + position: fixed; + left: 300px; + top: 116px; /* Account for toolbar height */ + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + padding-top: 40px; /* Reduced padding */ + pointer-events: none; /* Allow clicks to pass through to the input below */ + z-index: 10; + overflow-y: auto; /* Allow scrolling if content is too tall */ +} + +.emptyStateOverlay > * { + pointer-events: auto; /* Re-enable pointer events for the actual content */ +} + +body:global(.expanded) { + .emptyStateOverlay { + left: $sidebarCollapsedWidth; + } + + .chatForm { + left: $sidebarCollapsedWidth; + } +} + +.messagesContainer { + display: flex; + flex-direction: column; + gap: 16px; + min-height: 0; /* Allow container to be smaller than content */ +} + +.message { + max-width: 70%; + padding: 12px 16px; + border-radius: 18px; + margin-bottom: 8px; + position: relative; + word-wrap: break-word; +} + +.message.user { + align-self: flex-end; + background-color: #007bff; + color: white; + margin-left: auto; + + code { + background-color: rgba(255, 255, 255, 0.2); + color: white; + } +} + +.message.agent { + align-self: flex-start; + background-color: white; + color: #333; + border: 1px solid #e1e5e9; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); +} + +.message.agent.error { + background-color: #f8d7da; + color: #721c24; + border-color: #f5c6cb; +} + +.messageContent { + font-size: 14px; + line-height: 1.4; + + // Markdown formatting styles + h1, h2, h3, h4, h5, h6 { + margin: 8px 0 4px 0; + font-weight: 600; + } + + h1 { font-size: 18px; } + h2 { font-size: 16px; } + h3 { font-size: 15px; } + h4, h5, h6 { font-size: 14px; } + + p { + margin: 4px 0; + } + + ul, ol { + margin: 4px 0; + padding-left: 20px; + } + + li { + margin: 2px 0; + } + + code { + background-color: rgba(0, 0, 0, 0.1); + padding: 2px 4px; + border-radius: 3px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 13px; + } + + pre { + background-color: rgba(0, 0, 0, 0.05); + padding: 8px; + border-radius: 4px; + overflow-x: auto; + margin: 4px 0; + } + + pre code { + background-color: transparent; + padding: 0; + } + + table { + border-collapse: collapse; + margin: 8px 0; + font-size: 13px; + } + + th, td { + border: 1px solid #ddd; + padding: 4px 8px; + text-align: left; + } + + th { + background-color: rgba(0, 0, 0, 0.05); + font-weight: 600; + } + + blockquote { + border-left: 3px solid #ddd; + padding-left: 8px; + margin: 4px 0; + font-style: italic; + } + + strong { + font-weight: 600; + } +} + +.messageTime { + font-size: 11px; + opacity: 0.7; + margin-top: 4px; + text-align: right; +} + +.message.agent .messageTime { + text-align: left; +} + +.typing { + display: flex; + gap: 4px; + align-items: center; +} + +.typing span { + width: 6px; + height: 6px; + border-radius: 50%; + background-color: #999; + animation: typing 1.4s infinite ease-in-out; +} + +.typing span:nth-child(1) { + animation-delay: -0.32s; +} + +.typing span:nth-child(2) { + animation-delay: -0.16s; +} + +@keyframes typing { + 0%, 80%, 100% { + transform: scale(0); + opacity: 0.5; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +.chatForm { + background-color: white; + border-top: 1px solid #e1e5e9; + padding: 16px 20px; + position: fixed; + bottom: 0; + left: 300px; + right: 0; + z-index: 20; +} + +.inputContainer { + display: flex; + gap: 12px; + align-items: center; +} + +.chatInput { + flex: 1; + padding: 12px 16px; + border: 1px solid #e1e5e9; + border-radius: 24px; + font-size: 14px; + outline: none; + resize: none; + transition: border-color 0.2s ease; +} + +.chatInput:focus { + border-color: #007bff; + box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); +} + +.chatInput:disabled { + background-color: #f8f9fa; + color: #6c757d; +} + +.sendButton { + padding: 12px 24px; + background-color: #007bff; + color: white; + border: none; + border-radius: 24px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: background-color 0.2s ease; + min-width: 80px; +} + +.sendButton:hover:not(:disabled) { + background-color: #0056b3; +} + +.sendButton:disabled { + background-color: #6c757d; + cursor: not-allowed; +} + +.emptyStateContainer { + text-align: center; + max-width: 600px; + display: flex; + flex-direction: column; + align-items: center; + gap: 32px; /* Increased gap for better spacing */ + width: 100%; +} + +.exampleQueries { + margin-top: 0; /* Remove margin since we're using gap in parent */ + width: 100%; + + h4 { + color: #6c757d; + font-size: 14px; + font-weight: 500; + margin-bottom: 16px; + } +} + +.queryExamples { + display: flex; + flex-direction: column; + gap: 8px; + align-items: center; +} + +.exampleButton { + background: white; + border: 1px solid #007bff; + color: #007bff; + padding: 10px 16px; + border-radius: 20px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s ease; + max-width: 350px; + text-align: center; + + &:hover { + background-color: #007bff; + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + } + + &:active { + transform: translateY(0); + } +} + +.warningMessage { + background-color: #fff3cd; + border: 1px solid #ffeaa7; + color: #856404; + padding: 12px 16px; + border-radius: 8px; + margin-bottom: 16px; + font-size: 14px; + line-height: 1.4; + position: relative; + max-width: 100%; + align-self: stretch; + display: flex; + align-items: flex-start; + gap: 8px; + + .warningIcon { + flex-shrink: 0; + margin-top: 2px; + } + + .warningContent { + flex: 1; + } + + strong { + font-weight: 600; + } +} diff --git a/src/icons/sparkle-solid.svg b/src/icons/sparkle-solid.svg new file mode 100644 index 0000000000..d79e26d6a2 --- /dev/null +++ b/src/icons/sparkle-solid.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/lib/AJAX.js b/src/lib/AJAX.js index 91b3d7506c..ea98d30a7b 100644 --- a/src/lib/AJAX.js +++ b/src/lib/AJAX.js @@ -98,12 +98,26 @@ export function request( notice: message, }); } else if (this.status >= 500) { + let json = {}; + try { + json = JSON.parse(this.responseText); + } catch { + p.reject({ + success: false, + message: 'Server Error', + error: 'Server Error', + errors: ['Server Error'], + notice: 'Server Error', + }); + return; + } + const message = json.message || json.error || json.notice || 'Server Error'; p.reject({ success: false, - message: 'Server Error', - error: 'Server Error', - errors: ['Server Error'], - notice: 'Server Error', + message: message, + error: message, + errors: json.errors || [message], + notice: message, }); } }; diff --git a/src/lib/AgentService.js b/src/lib/AgentService.js new file mode 100644 index 0000000000..fb0dfa6e54 --- /dev/null +++ b/src/lib/AgentService.js @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2016-present, Parse, LLC + * All rights reserved. + * + * This source code is licensed under the license found in the LICENSE file in + * the root directory of this source tree. + */ +import { post } from './AJAX'; + +/** + * Service class for handling AI agent API requests to different providers + */ +export default class AgentService { + /** + * Send a message to the configured AI model and get a response + * @param {string} message - The user's message + * @param {Object} modelConfig - The model configuration object + * @param {string} appSlug - The app slug to scope the request to + * @param {string|null} conversationId - Optional conversation ID to maintain context + * @param {Object} permissions - Permission settings for operations + * @returns {Promise<{response: string, conversationId: string}>} The AI's response and conversation ID + */ + static async sendMessage(message, modelConfig, appSlug, conversationId = null, permissions = {}) { + if (!modelConfig) { + throw new Error('Model configuration is required'); + } + + const { name } = modelConfig; + + if (!name) { + throw new Error('Model name is required in model configuration'); + } + + if (!appSlug) { + throw new Error('App slug is required to send message to agent'); + } + + try { + const requestBody = { + message: message, + modelName: name + }; + + // Include conversation ID if provided + if (conversationId) { + requestBody.conversationId = conversationId; + } + + // Include permissions if provided + if (permissions) { + requestBody.permissions = permissions; + } + + const response = await post(`/apps/${appSlug}/agent`, requestBody); + + if (response.error) { + throw new Error(response.error); + } + + return { + response: response.response, + conversationId: response.conversationId + }; + } catch (error) { + // Handle specific error types + if (error.message && error.message.includes('Permission Denied')) { + throw new Error('Permission denied. Please refresh the page and try again.'); + } + + if (error.message && error.message.includes('CSRF')) { + throw new Error('Security token expired. Please refresh the page and try again.'); + } + + // Handle network errors and other fetch-related errors + if (error.message && error.message.includes('fetch')) { + throw new Error('Network error: Unable to connect to agent service. Please check your internet connection.'); + } + + // Re-throw the original error if it's not a recognized type + throw error; + } + } + + /** + * Validate model configuration + * @param {Object} modelConfig - The model configuration object + * @returns {boolean} True if valid, throws error if invalid + */ + static validateModelConfig(modelConfig) { + if (!modelConfig) { + throw new Error('Model configuration is required'); + } + + const { name, provider, model, apiKey } = modelConfig; + + if (!name) { + throw new Error('Model name is required in model configuration'); + } + + if (!provider) { + throw new Error('Provider is required in model configuration'); + } + + if (!model) { + throw new Error('Model name is required in model configuration'); + } + + if (!apiKey) { + throw new Error('API key is required in model configuration'); + } + + return true; + } + +}