diff --git a/Parse-Dashboard/app.js b/Parse-Dashboard/app.js
index 7ecb6863fc..0c80d0d713 100644
--- a/Parse-Dashboard/app.js
+++ b/Parse-Dashboard/app.js
@@ -5,6 +5,7 @@ const packageJson = require('package-json');
const csrf = require('csurf');
const Authentication = require('./Authentication.js');
const fs = require('fs');
+const Parse = require('parse/node');
const settings = require('@back4app/back4app-settings');
const currentVersionFeatures = require('../package.json').parseDashboardFeatures;
@@ -171,6 +172,870 @@ 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', express.json({ limit: '1mb' }), async (req, res) => {
+ try {
+ const { message, modelName, conversationId, permissions, modelConfig: requestModelConfig } = 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' });
+ }
+
+ // A model config can arrive two ways:
+ // 1. From the request body — the user provided their own credentials via
+ // the in-UI Configure dialog (temporary localStorage-based flow).
+ // 2. From the dashboard config file (config.agent.models).
+ // The request-provided config takes precedence when it is complete.
+ const hasRequestModelConfig =
+ requestModelConfig &&
+ requestModelConfig.provider &&
+ requestModelConfig.model &&
+ requestModelConfig.apiKey;
+
+ if (!hasRequestModelConfig &&
+ (!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` });
+ }
+
+ // Resolve the model config: request-provided wins, else look it up in the file.
+ const modelConfig = hasRequestModelConfig
+ ? requestModelConfig
+ : 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 = globalThis.fetch; // Node 18+ global fetch (node-fetch nao instalado no production2)
+
+ 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/package.json b/package.json
index a0299ab68f..9a30764e54 100644
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"@babel/runtime-corejs3": "7.20.13",
"@back4app/back4app-settings": "1.6.5",
"@back4app2/react-components": "1.0.0-beta.440.130",
+ "@back4app2/sdk": "^1.0.0-beta.440.174",
"@monaco-editor/react": "4.7.0",
"@paddle/paddle-js": "1.4.2",
"@sentry/react": "8.52.0",
@@ -101,6 +102,7 @@
"semver": "7.5.2",
"sweetalert2": "11.10.5",
"sweetalert2-react-content": "1.0.1",
+ "ts-invariant": "^0.10.3",
"typescript": "4.8.3",
"util": "^0.12.5",
"yup": "1.3.2"
@@ -125,7 +127,7 @@
"babel-eslint": "10.1.0",
"babel-loader": "9.1.2",
"babel-plugin-transform-object-rest-spread": "6.26.0",
- "core-js": "3.6.5",
+ "core-js": "3.28.0",
"cross-env": "7.0.2",
"css-loader": "6.7.3",
"eslint": "8.24.0",
@@ -219,5 +221,8 @@
"*.css",
"*.scss",
"./src/components/B4ACodeTree/B4ACodeTree.react.js"
- ]
+ ],
+ "overrides": {
+ "tslib": "2.6.2"
+ }
}
diff --git a/src/components/BrowserCell/B4aBrowserCell.scss b/src/components/BrowserCell/B4aBrowserCell.scss
index ca693b4951..32eee847ec 100644
--- a/src/components/BrowserCell/B4aBrowserCell.scss
+++ b/src/components/BrowserCell/B4aBrowserCell.scss
@@ -46,6 +46,72 @@
}
}
+// Multi-cell (shift-click) selection highlight. The B4a BrowserCell references
+// these classes; without them the selection had no visual feedback.
+.selected {
+ background-color: rgba(22, 105, 252, 0.3);
+}
+
+.leftBorder {
+ position: relative;
+
+ &:after {
+ position: absolute;
+ pointer-events: none;
+ content: '';
+ border-left: 2px solid #1669fc;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+}
+
+.rightBorder {
+ position: relative;
+
+ &:after {
+ position: absolute;
+ pointer-events: none;
+ content: '';
+ border-right: 2px solid #1669fc;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+}
+
+.topBorder {
+ position: relative;
+
+ &:after {
+ position: absolute;
+ pointer-events: none;
+ content: '';
+ border-top: 2px solid #1669fc;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+}
+
+.bottomBorder {
+ position: relative;
+
+ &:after {
+ position: absolute;
+ pointer-events: none;
+ content: '';
+ border-bottom: 2px solid #1669fc;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ }
+}
+
// .readonly {
// color: #353446;
// opacity: .9;
diff --git a/src/components/BrowserRow/BrowserRow.react.js b/src/components/BrowserRow/BrowserRow.react.js
index f708c4b6e3..a26868f659 100644
--- a/src/components/BrowserRow/BrowserRow.react.js
+++ b/src/components/BrowserRow/BrowserRow.react.js
@@ -34,6 +34,8 @@ export default class BrowserRow extends Component {
rowWidth,
selection,
selectRow,
+ onMouseDownRowCheckBox,
+ onMouseOverRowCheckBox,
setCopyableValue,
setCurrent,
setEditing,
@@ -66,12 +68,16 @@ export default class BrowserRow extends Component {
}
return (
{ /** 34 -> extra padding to cover up last column */}
-
+ onMouseOverRowCheckBox && onMouseOverRowCheckBox(obj.id)}
+ >
selectRow(obj.id, e.target.checked)}
+ onMouseDown={e => onMouseDownRowCheckBox && onMouseDownRowCheckBox(e.target.checked)}
/>
{order.map(({ name, width, visible }, j) => {
diff --git a/src/components/Sidebar/B4aSidebar.scss b/src/components/Sidebar/B4aSidebar.scss
index c9a98bc5ba..037809a59a 100644
--- a/src/components/Sidebar/B4aSidebar.scss
+++ b/src/components/Sidebar/B4aSidebar.scss
@@ -346,6 +346,22 @@ $aiToolsOpenHeight: 110px;
& svg {
fill: currentColor;
}
+
+ // B4aBadge positions itself with `float: right`, which works in the default
+ // (block) header but is ignored here: this active header is a flex
+ // container, and floats do not apply to flex items — the badge would end up
+ // flush against the label. `margin-left: auto` is the flex equivalent.
+ //
+ // Matches the last span ONLY when it is not the only one, i.e. exactly when
+ // a badge follows the label. With no badge (or a collapsed sidebar, where
+ // the label span is not rendered) nothing matches, so the label is never
+ // pushed right.
+ > span:last-of-type:not(:only-of-type) {
+ margin-left: auto;
+ // The badge's 5px top offset compensates for the block layout; here
+ // align-items: center already handles it.
+ margin-top: 0;
+ }
}
}
diff --git a/src/components/Sidebar/B4aSidebarSection.react.js b/src/components/Sidebar/B4aSidebarSection.react.js
index 6d600e5e05..d296a04fb5 100644
--- a/src/components/Sidebar/B4aSidebarSection.react.js
+++ b/src/components/Sidebar/B4aSidebarSection.react.js
@@ -112,6 +112,10 @@ const getIconContent = (icon) => {
);
+ case 'b4a-ai':
+ // No dedicated colored active-state component; keep the sprite icon so it
+ // does not disappear when the Agent section is active.
+ return ;
default:
return null;
}
diff --git a/src/dashboard/Apps/AppsIndex.react.js b/src/dashboard/Apps/AppsIndex.react.js
index 622773aa07..714925e4b8 100644
--- a/src/dashboard/Apps/AppsIndex.react.js
+++ b/src/dashboard/Apps/AppsIndex.react.js
@@ -196,21 +196,8 @@ class AppsIndex extends React.Component {
);
}
- let upgradePrompt = null;
- if (this.props.newFeaturesInLatestVersion.length > 0) {
- const newFeaturesNodes = this.props.newFeaturesInLatestVersion.map(feature => (
- {feature}
- ));
- upgradePrompt = (
-
- Upgrade to the{' '}
-
- latest version
- {' '}
- of Backend Dashboard to get access to: {joinWithFinal('', newFeaturesNodes, ', ', ' and ')}.
-
- );
- }
+ // Upgrade prompt banner removed (was shown when newFeaturesInLatestVersion was set).
+ const upgradePrompt = null;
return (
diff --git a/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js
index 78d3487926..714811652c 100644
--- a/src/dashboard/Dashboard.js
+++ b/src/dashboard/Dashboard.js
@@ -12,6 +12,7 @@ import AppData from './AppData.react';
import AppsIndex from './Apps/AppsIndex.react';
import AppsManager from 'lib/AppsManager';
import Browser from './Data/Browser/Browser.react';
+import Agent from './Data/Agent/AgentV4.react';
// import CloudCode from './Data/CloudCode/B4ACloudCode.react';
import AppOverview from './Data/AppOverview/AppOverview.react';
import Config from './Data/Config/Config.react';
@@ -424,6 +425,7 @@ class Dashboard extends React.Component {
} />
} />
+ } />
} />
{JobsRoute}
diff --git a/src/dashboard/DashboardView.react.js b/src/dashboard/DashboardView.react.js
index e1aea4d9a3..52589e9537 100644
--- a/src/dashboard/DashboardView.react.js
+++ b/src/dashboard/DashboardView.react.js
@@ -344,6 +344,13 @@ export default class DashboardView extends React.Component {
subsections: apiSubSections
});
+ appSidebarSections.push({
+ name: 'Backend Agent',
+ icon: 'b4a-ai',
+ link: '/agent',
+ badgeParams: { label: 'NEW', color: 'green' },
+ });
+
const notificationSubSections = [
{
name: 'Email',
diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js
new file mode 100644
index 0000000000..89f065dd36
--- /dev/null
+++ b/src/dashboard/Data/Agent/Agent.react.js
@@ -0,0 +1,868 @@
+/*
+ * 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 B4aEmptyState from 'components/B4aEmptyState/B4aEmptyState.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 AgentConfigDialog from './AgentConfigDialog.react';
+import AppOverviewCodeEditorBlock from 'dashboard/Data/AppOverview/AppOverviewCodeEditorBlock.react';
+import { injectAgent, removeAgent } from './agentCloudProvisioning';
+import styles from './Agent.scss';
+import { withRouter } from 'lib/withRouter';
+import { CurrentApp } from 'context/currentApp';
+
+// The app environment variable (Cloud Code env) where the user's OpenAI API key
+// is stored, instead of localStorage.
+const AGENT_ENV_KEY = 'OPENAI_API_KEY';
+// The model list (name/provider/model) is stored as an app env var too, so the
+// whole agent config is per-app on the backend and never bleeds between apps.
+// It is base64-encoded: raw JSON (quotes/braces) gets mangled by the container's
+// env-var injection, so the Cloud Function couldn't parse it.
+const AGENT_MODELS_ENV_KEY = 'AGENT_MODELS';
+
+// Base64 (UTF-8 safe) encode/decode for the model list env var.
+function encodeAgentModels(models) {
+ const json = JSON.stringify(models || []);
+ const bytes = new TextEncoder().encode(json);
+ let bin = '';
+ for (let i = 0; i < bytes.length; i++) { bin += String.fromCharCode(bytes[i]); }
+ return window.btoa(bin);
+}
+
+function decodeAgentModels(raw) {
+ if (!raw) { return []; }
+ // Legacy plain JSON.
+ try { const p = JSON.parse(raw); if (Array.isArray(p)) { return p; } } catch (e) { /* not plain JSON */ }
+ // base64(JSON).
+ try {
+ const bin = window.atob(raw);
+ const bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); }
+ const json = new TextDecoder().decode(bytes);
+ const p = JSON.parse(json);
+ if (Array.isArray(p)) { return p; }
+ } catch (e) { /* not base64 JSON */ }
+ return [];
+}
+
+@withRouter
+class Agent extends DashboardView {
+ static contextType = CurrentApp;
+
+ constructor(props) {
+ super(props);
+ this.section = 'Agent';
+ this.subsection = 'Agent';
+
+ this.state = {
+ messages: [],
+ inputValue: '',
+ isLoading: false,
+ selectedModel: this.getStoredSelectedModel(),
+ conversationId: null,
+ permissions: this.getStoredPermissions(),
+ // Force re-render key
+ permissionsKey: 0,
+ // User-provided agent config (from the in-UI Configure dialog), loaded on mount
+ userAgentConfig: null,
+ showConfigDialog: false,
+ };
+
+ this.browserMenuRef = React.createRef();
+ this.chatInputRef = React.createRef();
+ this.chatWindowRef = React.createRef();
+ this.action = new SidebarAction('Clear Chat', () => this.clearChat());
+ }
+
+ selectedModelStorageKey() {
+ const appSlug = this.context ? this.context.slug : null;
+ return appSlug ? `selectedAgentModel_${appSlug}` : null;
+ }
+
+ getStoredSelectedModel() {
+ const key = this.selectedModelStorageKey();
+ return key ? localStorage.getItem(key) : null;
+ }
+
+ 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,
+ };
+ }
+ }
+
+ // Save the config: a shared API key + a list of models. BOTH are stored as app
+ // env vars (Cloud Code env) — the key in OPENAI_API_KEY and the (non-secret)
+ // model list in AGENT_MODELS. Nothing lives in localStorage, so the whole
+ // config is per-app on the backend and never bleeds between apps. Returns a
+ // promise so the dialog can show "Saving…" and surface errors while the app
+ // rebuilds.
+ saveAgentConfig = async ({ apiKey, models }) => {
+ const cleanModels = (models || []).map(m => ({
+ name: m.name,
+ provider: m.provider || 'openai',
+ model: m.model,
+ }));
+ const app = this.context;
+
+ // 1. Prepare the updated Cloud Code tree FIRST. injectAgent throws a
+ // CloudCollisionError if a same-named file the user owns is in the way —
+ // doing this before any write means a collision aborts cleanly with
+ // nothing half-applied.
+ let newTree = null;
+ if (app && app.getCloudCode && app.saveCloudCode) {
+ const current = await app.getCloudCode();
+ newTree = injectAgent((current && current.tree) || []);
+ }
+
+ // 2. Store the shared API key AND the model list as app env vars.
+ if (app && app.getEnvVars && app.updateEnvVars) {
+ const existing = await app.getEnvVars();
+ const envVars = (existing && existing.envVars) || {};
+ const next = { ...envVars, [AGENT_MODELS_ENV_KEY]: encodeAgentModels(cleanModels) };
+ if (apiKey) {
+ next[AGENT_ENV_KEY] = apiKey;
+ }
+ await app.updateEnvVars(next);
+ }
+
+ // 3. Deploy the agent Cloud Function into the app's container.
+ if (newTree && app && app.saveCloudCode) {
+ await app.saveCloudCode(newTree);
+ }
+
+ // 4. Keep the full config (each model carries the shared key) in memory.
+ const config = { models: cleanModels.map(m => ({ ...m, apiKey })) };
+ this.setState({ userAgentConfig: config }, () => {
+ if (cleanModels[0]) {
+ this.setSelectedModel(cleanModels[0].name);
+ }
+ });
+ }
+
+ // Delete the whole agent for this app: remove the OPENAI_API_KEY and
+ // AGENT_MODELS env vars, clear the per-app UI selection, and reset in-memory
+ // state back to the empty ("Configure") state. Triggers an app rebuild.
+ deleteAgentConfig = async () => {
+ const app = this.context;
+
+ if (app && app.getEnvVars && app.updateEnvVars) {
+ const existing = await app.getEnvVars();
+ const envVars = { ...((existing && existing.envVars) || {}) };
+ delete envVars[AGENT_ENV_KEY];
+ delete envVars[AGENT_MODELS_ENV_KEY];
+ await app.updateEnvVars(envVars);
+ }
+
+ // Remove our managed Cloud Code file + the require() line. Best-effort: a
+ // failure here shouldn't block clearing the config.
+ if (app && app.getCloudCode && app.saveCloudCode) {
+ try {
+ const current = await app.getCloudCode();
+ const newTree = removeAgent((current && current.tree) || []);
+ await app.saveCloudCode(newTree);
+ } catch (error) {
+ console.warn('Failed to remove agent Cloud Code:', error);
+ }
+ }
+
+ const selKey = this.selectedModelStorageKey();
+ if (selKey) {
+ localStorage.removeItem(selKey);
+ }
+
+ this.setState({
+ userAgentConfig: null,
+ selectedModel: null,
+ showConfigDialog: false,
+ });
+ }
+
+ // Load the effective config entirely from the app environment variables: the
+ // model list from AGENT_MODELS + the shared API key from OPENAI_API_KEY. Both
+ // are per-app on the backend.
+ async loadAgentConfig() {
+ let apiKey;
+ let models = [];
+ try {
+ if (this.context && this.context.getEnvVars) {
+ const existing = await this.context.getEnvVars();
+ const envVars = (existing && existing.envVars) || {};
+ apiKey = envVars[AGENT_ENV_KEY];
+ models = decodeAgentModels(envVars[AGENT_MODELS_ENV_KEY]);
+ }
+ } catch (error) {
+ console.warn('Failed to read agent config from env vars:', error);
+ }
+
+ if (models.length > 0 && apiKey) {
+ const config = {
+ models: models.map(m => ({
+ name: m.name,
+ provider: m.provider || 'openai',
+ model: m.model,
+ apiKey,
+ })),
+ };
+ this.setState({ userAgentConfig: config }, () => this.setDefaultModel());
+ } else {
+ this.setState({ userAgentConfig: null }, () => this.setDefaultModel());
+ }
+ }
+
+ // Effective config: user-provided (localStorage) takes precedence over the
+ // dashboard config file (props.agentConfig).
+ getAgentConfig() {
+ return this.state.userAgentConfig || this.props.agentConfig;
+ }
+
+ 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' });
+ }
+
+ // Load user-provided agent config (non-secret parts from localStorage, API
+ // key from the app env var) now that the app context (slug) is available.
+ this._loadedSlug = this.context ? this.context.slug : null;
+ this.loadAgentConfig();
+
+ // 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) {
+ // The Agent component instance is reused when switching apps (only the app
+ // context/slug changes, not the mounted component). Reset per-app in-memory
+ // state and reload from the new app's storage so the previous app's model
+ // name / config / chat don't bleed across apps.
+ const currentSlug = this.context ? this.context.slug : null;
+ if (currentSlug !== this._loadedSlug) {
+ this._loadedSlug = currentSlug;
+ this.setState(
+ { selectedModel: null, userAgentConfig: null, messages: [], conversationId: null },
+ () => {
+ this.loadAgentConfig();
+ this.loadSavedChatState();
+ }
+ );
+ return;
+ }
+
+ // 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() {
+ // Pick the selected model when none is set. Prefer the per-app stored choice
+ // (the constructor can't read it — no app slug yet); fall back to the first.
+ const agentConfig = this.getAgentConfig();
+ const { selectedModel } = this.state;
+ const models = agentConfig?.models || [];
+
+ if (!selectedModel && models.length > 0) {
+ const stored = this.getStoredSelectedModel();
+ const valid = stored && models.some(m => m.name === stored);
+ this.setSelectedModel(valid ? stored : models[0].name);
+ }
+ }
+
+ setSelectedModel(modelName) {
+ this.setState({ selectedModel: modelName });
+ const key = this.selectedModelStorageKey();
+ if (key) {
+ localStorage.setItem(key, 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 });
+ }
+
+ handleKeyDown = (event) => {
+ // Enter submits; Shift+Enter inserts a newline (default textarea behavior).
+ if (event.key === 'Enter' && !event.shiftKey) {
+ event.preventDefault();
+ this.handleSubmit(event);
+ }
+ }
+
+ 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.getAgentConfig();
+
+ 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);
+
+ // App context (carries sendAgentMessage, which posts to the back4app API).
+ if (!this.context || typeof this.context.sendAgentMessage !== 'function') {
+ throw new Error('App context not available');
+ }
+
+ // Build the recent conversation history to send (the server-side agent is
+ // stateless). `messages` was captured before the current user message was
+ // added, so it is exactly the prior history.
+ const history = messages
+ .filter(m => (m.type === 'user' || m.type === 'agent') && !m.isError && m.content)
+ .map(m => ({ role: m.type === 'user' ? 'user' : 'assistant', content: m.content }));
+
+ // Get response from the AI service (server-side, key never leaves backend).
+ const result = await AgentService.sendMessage(
+ inputValue.trim(),
+ modelConfig,
+ this.context,
+ this.state.permissions,
+ history
+ );
+
+ 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.getAgentConfig();
+ 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) => (
+
+ )}
+ {}}
+ >
+ {permissionOperations.map((operation) => (
+
+ this.setState({ showConfigDialog: true })}
+ >
+
+
+ this.clearChat()}
+ >
+ Clear
+
+
+ );
+ }
+
+ formatMessageContent(content) {
+ // Render fenced code blocks with the read-only Monaco editor (same component
+ // Cloud Code / deployments use), and the surrounding prose with Markdown.
+ const text = String(content || '');
+ const fence = /```(\w*)[ \t]*\r?\n([\s\S]*?)```/g;
+ const parts = [];
+ let lastIndex = 0;
+ let match;
+ let key = 0;
+
+ while ((match = fence.exec(text)) !== null) {
+ const before = text.slice(lastIndex, match.index);
+ if (before.trim()) {
+ parts.push();
+ }
+ const lang = match[1] || 'plaintext';
+ const code = match[2].replace(/\n$/, '');
+ parts.push();
+ lastIndex = fence.lastIndex;
+ }
+
+ const rest = text.slice(lastIndex);
+ if (rest.trim()) {
+ parts.push();
+ }
+
+ if (parts.length === 0) {
+ return ;
+ }
+ return <>{parts}>;
+ }
+
+ renderMessages() {
+ const { messages, isLoading } = this.state;
+
+ if (messages.length === 0) {
+ return null; // Empty state is now handled as overlay
+ }
+
+ return (
+
+ );
+ }
+}
+
+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..a2b93c5b1b
--- /dev/null
+++ b/src/dashboard/Data/Agent/Agent.scss
@@ -0,0 +1,499 @@
+/*
+ * 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';
+
+// Dark theme palette (matches the production2 dashboard)
+$agentBg: #0f1c32;
+$agentSurface: #17253d;
+$agentBorder: rgba(255, 255, 255, 0.12);
+$agentText: #e2e8f0;
+$agentMuted: #94a3b8;
+$agentAccent: #1669fc;
+
+.agentContainer {
+ display: flex;
+ flex-direction: column;
+ /* No full-height background: the toolbar has a top margin (header area) and a
+ navy background here would bleed above the gray toolbar. The chat window and
+ the empty-state overlay carry the navy background below the toolbar. */
+}
+
+.toolbarAction {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ padding: 0 12px;
+ cursor: pointer;
+ color: #ffffff;
+ font-size: 13px;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.08);
+ }
+}
+
+.toolbarIconBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 44px;
+ cursor: pointer;
+
+ &:hover {
+ background: rgba(255, 255, 255, 0.08);
+ }
+}
+
+.activeModel {
+ display: inline-flex;
+ align-items: center;
+ height: 100%;
+ padding: 0 8px;
+ color: #94a3b8;
+ font-size: 13px;
+ white-space: nowrap;
+}
+
+/* Fixed box below the header (63px) + toolbar (64px) = 127px, spanning from the
+ sidebar to the right edge. A flex column: the chat window scrolls, the input
+ sits at the bottom. */
+.chatContainer {
+ position: fixed;
+ top: 127px;
+ left: 280px; /* $b4aSidebarWidth */
+ right: 0;
+ bottom: 0;
+ display: flex;
+ flex-direction: column;
+ background-color: $agentBg;
+}
+
+.chatWindow {
+ flex: 1 1 auto;
+ min-height: 0; /* critical: lets the flex child scroll instead of growing */
+ overflow-y: auto;
+ padding: 20px;
+ scroll-behavior: smooth;
+}
+
+.emptyStateOverlay {
+ position: fixed;
+ left: 280px;
+ top: 127px; /* below production2 toolbar (header 63 + toolbar 64) */
+ 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 */
+ background-color: $agentBg;
+}
+
+.emptyStateOverlay > * {
+ pointer-events: auto; /* Re-enable pointer events for the actual content */
+}
+
+.welcomeState {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 24px;
+ width: 100%;
+}
+
+body:global(.expanded) {
+ .emptyStateOverlay {
+ left: 88px; /* $b4aSidebarCollapsedWidth */
+ }
+
+ .chatContainer {
+ left: 88px; /* $b4aSidebarCollapsedWidth */
+ }
+}
+
+.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: $agentAccent;
+ color: #ffffff;
+ margin-left: auto;
+
+ code {
+ background-color: rgba(255, 255, 255, 0.2);
+ color: #ffffff;
+ }
+}
+
+.message.agent {
+ align-self: flex-start;
+ // Agent replies can contain code editors — give them more room than user
+ // bubbles so code is readable.
+ max-width: 85%;
+ background-color: $agentSurface;
+ color: $agentText;
+ border: 1px solid $agentBorder;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
+}
+
+.message.agent.error {
+ background-color: #3b1f24;
+ color: #f8b7bf;
+ border-color: #7a2e38;
+}
+
+.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(255, 255, 255, 0.12);
+ padding: 2px 5px;
+ border-radius: 4px;
+ font-family: 'Monaco', 'Menlo', monospace;
+ font-size: 13px;
+ }
+
+ // Code blocks come from CodeSnippet (PrismJS with the line-numbers plugin).
+ // Prism's highlight JS never runs here (the markdown is server-rendered to a
+ // string), leaving a light theme + an empty line-numbers gutter that looked
+ // broken. Normalize to a clean dark block, scoped to the chat.
+ pre,
+ pre[class*='language-'] {
+ background: rgba(0, 0, 0, 0.35) !important;
+ color: #e2e8f0 !important;
+ padding: 12px 14px !important; // also removes the line-numbers gutter padding
+ border: none !important;
+ border-radius: 6px;
+ overflow-x: auto;
+ margin: 6px 0;
+ min-height: 0 !important;
+ text-shadow: none !important;
+ font-family: 'Monaco', 'Menlo', monospace;
+ font-size: 13px;
+ line-height: 1.5;
+
+ code {
+ background: transparent !important;
+ color: inherit !important;
+ padding: 0 !important;
+ text-shadow: none !important;
+ font-size: inherit;
+ white-space: pre;
+ }
+ }
+
+ // The line-numbers plugin's rows aren't generated without its JS.
+ :global(.line-numbers-rows) {
+ display: none !important;
+ }
+
+ table {
+ border-collapse: collapse;
+ margin: 8px 0;
+ font-size: 13px;
+ }
+
+ th, td {
+ border: 1px solid $agentBorder;
+ padding: 4px 8px;
+ text-align: left;
+ }
+
+ th {
+ background-color: rgba(255, 255, 255, 0.06);
+ font-weight: 600;
+ }
+
+ blockquote {
+ border-left: 3px solid $agentBorder;
+ 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;
+}
+
+// Centered hint shown inside the chat window when an agent exists but has no
+// messages yet — keeps the input visible (unlike the full-screen overlay).
+.inlineEmpty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ min-height: 320px;
+}
+
+.progressRow {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.progressText {
+ color: $agentMuted;
+ font-size: 13px;
+ font-family: 'Monaco', 'Menlo', monospace;
+}
+
+.typing {
+ display: flex;
+ gap: 4px;
+ align-items: center;
+}
+
+.typing span {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background-color: $agentMuted;
+ 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;
+ }
+}
+
+/* Static flex child at the bottom of .chatContainer (no longer fixed). */
+.chatForm {
+ flex: 0 0 auto;
+ background-color: $agentBg;
+ border-top: 1px solid $agentBorder;
+ padding: 16px 20px;
+}
+
+.inputContainer {
+ display: flex;
+ gap: 12px;
+ align-items: flex-end;
+}
+
+.chatInput {
+ flex: 1;
+ padding: 12px 16px;
+ border: 1px solid $agentBorder;
+ border-radius: 20px;
+ font-size: 14px;
+ line-height: 1.4;
+ font-family: inherit;
+ outline: none;
+ resize: none;
+ transition: border-color 0.2s ease;
+ background-color: $agentSurface;
+ color: $agentText;
+ min-height: 44px;
+ max-height: 160px; /* grow with content up to here, then scroll */
+ overflow-y: auto;
+
+ &::placeholder {
+ color: $agentMuted;
+ }
+}
+
+.chatInput:focus {
+ border-color: $agentAccent;
+ box-shadow: 0 0 0 2px rgba(22, 105, 252, 0.35);
+}
+
+.chatInput:disabled {
+ background-color: rgba(255, 255, 255, 0.04);
+ color: $agentMuted;
+}
+
+.sendButton {
+ padding: 12px 24px;
+ background-color: $agentAccent;
+ color: #ffffff;
+ border: 1px solid transparent; /* reserve the border; colored only when usable */
+ border-radius: 24px;
+ font-size: 14px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background-color 0.2s ease, border-color 0.2s ease;
+ min-width: 80px;
+}
+
+.sendButton:not(:disabled) {
+ border-color: $agentBorder; /* same as the message cards — looks like a button */
+}
+
+.sendButton:hover:not(:disabled) {
+ background-color: #0f52cc;
+}
+
+.sendButton:disabled {
+ background-color: rgba(255, 255, 255, 0.15);
+ color: $agentMuted;
+ 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: $agentMuted;
+ font-size: 14px;
+ font-weight: 500;
+ margin-bottom: 16px;
+ text-align: center;
+ }
+}
+
+.queryExamples {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ align-items: center;
+}
+
+.exampleButton {
+ background: transparent;
+ border: 1px solid $agentAccent;
+ color: #6ea8ff;
+ 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: $agentAccent;
+ color: #ffffff;
+ transform: translateY(-1px);
+ box-shadow: 0 2px 8px rgba(22, 105, 252, 0.4);
+ }
+
+ &:active {
+ transform: translateY(0);
+ }
+}
+
+.warningMessage {
+ background-color: rgba(255, 193, 7, 0.12);
+ border: 1px solid rgba(255, 193, 7, 0.4);
+ color: #ffd97a;
+ 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;
+ }
+}
+
+// Subtitle text inside the B4aModal confirmations (delete / clear key),
+// matching the Cloud Code section's modal styling.
+.subtitleModal {
+ font-size: 14px;
+ color: #303338;
+ opacity: 0.7;
+}
diff --git a/src/dashboard/Data/Agent/AgentConfigDialog.react.js b/src/dashboard/Data/Agent/AgentConfigDialog.react.js
new file mode 100644
index 0000000000..e1205c01bb
--- /dev/null
+++ b/src/dashboard/Data/Agent/AgentConfigDialog.react.js
@@ -0,0 +1,245 @@
+/*
+ * 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 B4aFormModal from 'components/FormModal/B4aFormModal.react';
+import Dropdown from 'components/Dropdown/Dropdown.react';
+import Field from 'components/Field/Field.react';
+import Icon from 'components/Icon/Icon.react';
+import Label from 'components/Label/Label.react';
+import Option from 'components/Dropdown/Option.react';
+import TextInput from 'components/TextInput/TextInput.react';
+import React from 'react';
+
+/**
+ * Dialog to configure the AI agent from the UI. Supports MULTIPLE models
+ * (add/edit/delete), all sharing a single OpenAI API key. The key is stored as
+ * the app env var (OPENAI_API_KEY); the model list (non-secret) is stored in the
+ * AGENT_MODELS env var. Only OpenAI is supported for now, so provider is fixed.
+ */
+
+// Curated list of common OpenAI models for the dropdown. Not exhaustive — the
+// "Custom…" option lets the user type any model id (e.g. a brand-new release),
+// so this list can lag behind OpenAI without blocking anyone.
+const CURATED_MODELS = [
+ 'gpt-4o',
+ 'gpt-4o-mini',
+ 'gpt-4.1',
+ 'gpt-4.1-mini',
+ 'o3',
+ 'o3-mini',
+ 'gpt-5',
+ 'gpt-5-mini',
+];
+const CUSTOM_OPTION = '__custom__';
+
+const emptyModel = () => ({ name: '', model: '', custom: false });
+
+// A saved model whose id is not in the curated list is shown as "Custom".
+const modelFromInitial = m => {
+ const model = m.model || '';
+ return { name: m.name || '', model, custom: !!model && !CURATED_MODELS.includes(model) };
+};
+
+export default class AgentConfigDialog extends React.Component {
+ constructor(props) {
+ super(props);
+ this.state = this.stateFromProps();
+ }
+
+ stateFromProps() {
+ const initial = this.props.initialModels || [];
+ const models = initial.length ? initial.map(modelFromInitial) : [emptyModel()];
+ return { apiKey: this.props.initialApiKey || '', models };
+ }
+
+ componentDidUpdate(prevProps) {
+ if (!prevProps.open && this.props.open) {
+ this.setState(this.stateFromProps());
+ }
+ }
+
+ clearFields = () => this.setState(this.stateFromProps());
+
+ updateModel(index, field, value) {
+ const models = this.state.models.map((m, i) =>
+ i === index ? { ...m, [field]: String(value ?? '') } : m
+ );
+ this.setState({ models });
+ }
+
+ // Handle a pick in the model dropdown. Selecting "Custom…" switches the row to
+ // a free-text id (cleared so the user types one); picking a curated model sets
+ // the id directly.
+ selectModel(index, value) {
+ const models = this.state.models.map((m, i) => {
+ if (i !== index) { return m; }
+ if (value === CUSTOM_OPTION) { return { ...m, custom: true, model: '' }; }
+ return { ...m, custom: false, model: value };
+ });
+ this.setState({ models });
+ }
+
+ addModel = () => {
+ this.setState({ models: [...this.state.models, emptyModel()] });
+ };
+
+ removeModel(index) {
+ const models = this.state.models.filter((_, i) => i !== index);
+ this.setState({ models: models.length ? models : [emptyModel()] });
+ }
+
+ valid() {
+ // The display name is optional (it falls back to the model id on save); only
+ // the API key and the model id are required.
+ return (
+ this.state.apiKey.trim() !== '' &&
+ this.state.models.length > 0 &&
+ this.state.models.every(m => m.model.trim() !== '')
+ );
+ }
+
+ render() {
+ const { models } = this.state;
+ return (
+
+ Promise.resolve(
+ this.props.onConfirm({
+ apiKey: this.state.apiKey.trim(),
+ models: this.state.models.map(m => ({
+ name: m.name.trim() || m.model.trim(),
+ provider: 'openai',
+ model: m.model.trim(),
+ })),
+ })
+ )
+ }
+ >
+
+ Heads up: saving will install the agent into your app's{' '}
+ Cloud Code (a managed file cloud/dashboard-agent/index.js{' '}
+ and a require in your main.js), set the{' '}
+ OPENAI_API_KEY and AGENT_MODELS environment variables, and{' '}
+ redeploy your app. The agent then runs inside your app's container.
+