From e0d94174bb7cbbae1f1ed161ff7df22fe57ff79e Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 22:42:09 -0300 Subject: [PATCH 01/68] Porta feature AI Agent para o production2 (identidade visual B4a) Fase A do porte production1 -> production2: - src/dashboard/Data/Agent/ (Agent.react.js, Agent.scss, AgentConfigDialog.react.js) e src/lib/AgentService.js - Empty state com B4aEmptyState + botao Configure; dialog com B4aFormModal (padrao visual do production2) - Form "Configure" (localStorage, provider OpenAI) para o usuario informar a apiKey pela UI, sem editar o config.json - Rota /agent no Dashboard.js + secao Agent na sidebar (DashboardView) - Parse-Dashboard/app.js: porta a rota POST /apps/:appId/agent + makeOpenAIRequest + database tools + conversas; adiciona require('parse/node'); usa fetch global (Node 18, sem node-fetch); express.json() escopado so na rota (production2 nao tem body-parser global) Co-Authored-By: Claude Opus 4.8 (1M context) --- Parse-Dashboard/app.js | 865 ++++++++++++++++++ src/dashboard/Dashboard.js | 2 + src/dashboard/DashboardView.react.js | 7 + src/dashboard/Data/Agent/Agent.react.js | 675 ++++++++++++++ src/dashboard/Data/Agent/Agent.scss | 373 ++++++++ .../Data/Agent/AgentConfigDialog.react.js | 131 +++ src/lib/AgentService.js | 127 +++ 7 files changed, 2180 insertions(+) create mode 100644 src/dashboard/Data/Agent/Agent.react.js create mode 100644 src/dashboard/Data/Agent/Agent.scss create mode 100644 src/dashboard/Data/Agent/AgentConfigDialog.react.js create mode 100644 src/lib/AgentService.js 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/src/dashboard/Dashboard.js b/src/dashboard/Dashboard.js index 78d3487926..209466ba66 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/Agent.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..0bbcb3e99b 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: 'Agent', + icon: 'collaborate-solid', + link: '/agent', + subsections: [] + }); + 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..8371c8431c --- /dev/null +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -0,0 +1,675 @@ +/* + * 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 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 = '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()); + } + + 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, + }; + } + } + + agentConfigStorageKey() { + const appSlug = this.context ? this.context.slug : null; + return appSlug ? `agentUserConfig_${appSlug}` : null; + } + + getStoredAgentConfig() { + try { + const key = this.agentConfigStorageKey(); + if (!key) { return null; } + const stored = localStorage.getItem(key); + if (!stored) { return null; } + const parsed = JSON.parse(stored); + if (!parsed || !Array.isArray(parsed.models) || parsed.models.length === 0) { return null; } + return parsed; + } catch (error) { + console.warn('Failed to parse stored agent config:', error); + return null; + } + } + + saveAgentConfig = (model) => { + const config = { models: [model] }; + const key = this.agentConfigStorageKey(); + if (key) { + try { + localStorage.setItem(key, JSON.stringify(config)); + } catch (error) { + console.warn('Failed to save agent config:', error); + } + } + this.setState({ userAgentConfig: config, showConfigDialog: false }, () => { + this.setSelectedModel(model.name); + }); + } + + // 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 (from the Configure dialog) now that + // the app context (slug) is available. + const storedAgentConfig = this.getStoredAgentConfig(); + if (storedAgentConfig) { + this.setState({ userAgentConfig: storedAgentConfig }, () => this.setDefaultModel()); + } else { + 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.getAgentConfig(); + 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.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); + + // 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.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) => ( + + {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.setState({ showConfigDialog: true })} /> + 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.getAgentConfig(); + 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 ? ( + this.setState({ showConfigDialog: true })} + /> + ) : ( +
+ +
+

Try asking:

+
+ + + +
+
+
+ )} +
+ )} + this.setState({ showConfigDialog: false })} + /> +
+ ); + } +} + +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/dashboard/Data/Agent/AgentConfigDialog.react.js b/src/dashboard/Data/Agent/AgentConfigDialog.react.js new file mode 100644 index 0000000000..176516343e --- /dev/null +++ b/src/dashboard/Data/Agent/AgentConfigDialog.react.js @@ -0,0 +1,131 @@ +/* + * 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 Field from 'components/Field/Field.react'; +import Label from 'components/Label/Label.react'; +import TextInput from 'components/TextInput/TextInput.react'; +import React from 'react'; + +/** + * Dialog to let the dashboard user provide their own AI agent credentials + * from the UI, instead of editing the dashboard configuration file. + * + * Only OpenAI is supported for now (see Parse-Dashboard/app.js); the provider + * is fixed to 'openai'. + * + * NOTE: for now this persists to localStorage (see Agent.react.js). That is a + * temporary/insecure store — the intended design stores the key server-side + * (app Cloud Code env var). This dialog is UI-first so we can iterate on UX. + */ +export default class AgentConfigDialog extends React.Component { + constructor(props) { + super(props); + this.state = this.fieldsFromProps(); + } + + fieldsFromProps() { + const model = this.props.initialModel || {}; + return { + name: model.name || 'My model', + model: model.model || '', + apiKey: model.apiKey || '', + }; + } + + componentDidUpdate(prevProps) { + // Refresh the fields from the current config each time the dialog opens. + if (!prevProps.open && this.props.open) { + this.setState(this.fieldsFromProps()); + } + } + + valid() { + return ( + this.state.name.trim() !== '' && + this.state.model.trim() !== '' && + this.state.apiKey.trim() !== '' + ); + } + + clearFields = () => { + this.setState(this.fieldsFromProps()); + }; + + render() { + return ( + { + this.props.onConfirm({ + name: this.state.name.trim(), + provider: 'openai', + model: this.state.model.trim(), + apiKey: this.state.apiKey.trim(), + }); + return Promise.resolve(); + }} + > + } + input={ + {}} + /> + } + /> + } + input={ + this.setState({ name: String(value ?? '') })} + /> + } + /> + } + input={ + this.setState({ model: String(value ?? '') })} + /> + } + /> + } + input={ + + ); + } +} diff --git a/src/lib/AgentService.js b/src/lib/AgentService.js new file mode 100644 index 0000000000..97a1855284 --- /dev/null +++ b/src/lib/AgentService.js @@ -0,0 +1,127 @@ +/* + * 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 + }; + + // If the model config carries its own credentials (provided by the user + // through the in-UI Configure dialog, not the dashboard config file), + // forward them so the server can use them instead of config.agent. + if (modelConfig.apiKey && modelConfig.provider && modelConfig.model) { + requestBody.modelConfig = { + name: modelConfig.name, + provider: modelConfig.provider, + model: modelConfig.model, + apiKey: modelConfig.apiKey, + }; + } + + // 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; + } + +} From d4019f7a10e89487f993d8944f8ba9e871e63157 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 22:44:34 -0300 Subject: [PATCH 02/68] =?UTF-8?q?Alinha=20core-js=20para=203.28.0=20(dep?= =?UTF-8?q?=20+=20devDep)=20=E2=80=94=20corrige=20dev=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O package.json declarava core-js em 3.28.0 (dependencies) e 3.6.5 (devDependencies). Sem lockfile, o npm install deixava o 3.6.5 no topo, que nao tem modulos como es.error.cause.js/es.array.at.js/etc. O babel usa corejs '3.28' (useBuiltIns 'entry'), entao o `npm run dashboard` (webpack build.config.js) quebrava com ~40 erros "Can't resolve 'core-js/modules/...'". Alinhado ambos para 3.28.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a0299ab68f..0ffd4a1e90 100644 --- a/package.json +++ b/package.json @@ -125,7 +125,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", From 4a1be8b800c03403f3a2d494787d8717168975b0 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 22:48:02 -0300 Subject: [PATCH 03/68] =?UTF-8?q?Adiciona=20override=20do=20tslib=202.6.2?= =?UTF-8?q?=20=E2=80=94=20corrige=20tela=20branca=20(=5F=5FspreadArray)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sem lockfile, o npm resolvia um tslib antigo (sem __spreadArray) no topo. O @amplitude/analytics-core chama tslib.__spreadArray em runtime -> TypeError -> tela branca no dashboard. tslib e transitivo (nao esta no package.json), entao forcei via "overrides" para 2.6.2 (tem __spreadArray, retrocompativel). Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 0ffd4a1e90..6e460fad6f 100644 --- a/package.json +++ b/package.json @@ -219,5 +219,8 @@ "*.css", "*.scss", "./src/components/B4ACodeTree/B4ACodeTree.react.js" - ] + ], + "overrides": { + "tslib": "2.6.2" + } } From bcfde113ed89c39e0f438db6e9514989853d29db Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 22:52:21 -0300 Subject: [PATCH 04/68] Agent: tema escuro (fundo #0f1c32) para casar com o dashboard do production2 - Agent.scss convertido do tema claro (production1) para o dark do production2: fundo #0f1c32, superficies/bolhas/input escuros, texto claro, acento azul. Layout/estrutura mantidos. - Toolbar: section "Core" -> "Agent" (breadcrumb correto). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 2 +- src/dashboard/Data/Agent/Agent.scss | 144 ++++++++++++++---------- 2 files changed, 86 insertions(+), 60 deletions(-) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index 8371c8431c..92c487920b 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -441,7 +441,7 @@ class Agent extends DashboardView { ]; return ( - + {models.length > 0 && ( * { 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: $sidebarCollapsedWidth; } - + .chatForm { left: $sidebarCollapsedWidth; } @@ -83,103 +101,103 @@ body:global(.expanded) { .message.user { align-self: flex-end; - background-color: #007bff; - color: white; + background-color: $agentAccent; + color: #ffffff; margin-left: auto; - + code { background-color: rgba(255, 255, 255, 0.2); - color: white; + color: #ffffff; } } .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); + 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: #f8d7da; - color: #721c24; - border-color: #f5c6cb; + 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(0, 0, 0, 0.1); + background-color: rgba(255, 255, 255, 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); + background-color: rgba(0, 0, 0, 0.35); 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; + border: 1px solid $agentBorder; padding: 4px 8px; text-align: left; } - + th { - background-color: rgba(0, 0, 0, 0.05); + background-color: rgba(255, 255, 255, 0.06); font-weight: 600; } - + blockquote { - border-left: 3px solid #ddd; + border-left: 3px solid $agentBorder; padding-left: 8px; margin: 4px 0; font-style: italic; } - + strong { font-weight: 600; } @@ -206,7 +224,7 @@ body:global(.expanded) { width: 6px; height: 6px; border-radius: 50%; - background-color: #999; + background-color: $agentMuted; animation: typing 1.4s infinite ease-in-out; } @@ -230,8 +248,8 @@ body:global(.expanded) { } .chatForm { - background-color: white; - border-top: 1px solid #e1e5e9; + background-color: $agentBg; + border-top: 1px solid $agentBorder; padding: 16px 20px; position: fixed; bottom: 0; @@ -249,28 +267,34 @@ body:global(.expanded) { .chatInput { flex: 1; padding: 12px 16px; - border: 1px solid #e1e5e9; + border: 1px solid $agentBorder; border-radius: 24px; font-size: 14px; outline: none; resize: none; transition: border-color 0.2s ease; + background-color: $agentSurface; + color: $agentText; + + &::placeholder { + color: $agentMuted; + } } .chatInput:focus { - border-color: #007bff; - box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25); + border-color: $agentAccent; + box-shadow: 0 0 0 2px rgba(22, 105, 252, 0.35); } .chatInput:disabled { - background-color: #f8f9fa; - color: #6c757d; + background-color: rgba(255, 255, 255, 0.04); + color: $agentMuted; } .sendButton { padding: 12px 24px; - background-color: #007bff; - color: white; + background-color: $agentAccent; + color: #ffffff; border: none; border-radius: 24px; font-size: 14px; @@ -281,11 +305,12 @@ body:global(.expanded) { } .sendButton:hover:not(:disabled) { - background-color: #0056b3; + background-color: #0f52cc; } .sendButton:disabled { - background-color: #6c757d; + background-color: rgba(255, 255, 255, 0.15); + color: $agentMuted; cursor: not-allowed; } @@ -302,12 +327,13 @@ body:global(.expanded) { .exampleQueries { margin-top: 0; /* Remove margin since we're using gap in parent */ width: 100%; - + h4 { - color: #6c757d; + color: $agentMuted; font-size: 14px; font-weight: 500; margin-bottom: 16px; + text-align: center; } } @@ -319,9 +345,9 @@ body:global(.expanded) { } .exampleButton { - background: white; - border: 1px solid #007bff; - color: #007bff; + background: transparent; + border: 1px solid $agentAccent; + color: #6ea8ff; padding: 10px 16px; border-radius: 20px; font-size: 13px; @@ -329,23 +355,23 @@ body:global(.expanded) { transition: all 0.2s ease; max-width: 350px; text-align: center; - + &:hover { - background-color: #007bff; - color: white; + background-color: $agentAccent; + color: #ffffff; transform: translateY(-1px); - box-shadow: 0 2px 8px rgba(0, 123, 255, 0.3); + box-shadow: 0 2px 8px rgba(22, 105, 252, 0.4); } - + &:active { transform: translateY(0); } } .warningMessage { - background-color: #fff3cd; - border: 1px solid #ffeaa7; - color: #856404; + 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; @@ -357,16 +383,16 @@ body:global(.expanded) { display: flex; align-items: flex-start; gap: 8px; - + .warningIcon { flex-shrink: 0; margin-top: 2px; } - + .warningContent { flex: 1; } - + strong { font-weight: 600; } From 8cd9944ebc1b595e5fa2eb8ba48e01d58d0a94b0 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 23:00:18 -0300 Subject: [PATCH 05/68] Filtros: adiciona matches (regex), onOrBefore e onOrAfter no production2 Porta da production1 os constraints que faltavam no production2: - matches (regex, String) -> query.matches(field, str, modifiers) - onOrBefore (Date) -> query.lessThanOrEqualTo - onOrAfter (Date) -> query.greaterThanOrEqualTo Definicoes em Constraints + FieldConstraints (String/Date) em src/lib/Filters.js, e os cases correspondentes em src/lib/queryFromFilters.js. O production2 ja tinha neq/keyNeq/stringContainsString. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/Filters.js | 22 ++++++++++++++++++++-- src/lib/queryFromFilters.js | 9 +++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/lib/Filters.js b/src/lib/Filters.js index 0539fa007c..8dc96b85db 100644 --- a/src/lib/Filters.js +++ b/src/lib/Filters.js @@ -64,18 +64,36 @@ export const Constraints = { composable: true, comparable: true, }, + matches: { + name: 'matches regex', + field: 'String', + composable: true, + comparable: true, + }, before: { name: 'is before', field: 'Date', composable: true, comparable: true, }, + onOrBefore: { + name: 'is on or before', + field: 'Date', + composable: true, + comparable: true, + }, after: { name: 'is after', field: 'Date', composable: true, comparable: true, }, + onOrAfter: { + name: 'is on or after', + field: 'Date', + composable: true, + comparable: true, + }, containsString: { name: 'contains string', field: 'String', @@ -174,8 +192,8 @@ export const FieldConstraints = { Pointer: ['exists', 'dne', 'eq', 'neq', 'unique', 'isNull'], Boolean: ['exists', 'dne', 'eq', 'unique', 'isNull'], Number: ['exists', 'dne', 'eq', 'neq', 'lt', 'lte', 'gt', 'gte', 'unique', 'isNull'], - String: ['exists', 'dne', 'eq', 'neq', 'starts', 'ends', 'stringContainsString', 'unique', 'isNull'], - Date: ['exists', 'dne', 'before', 'after', 'unique', 'isNull'], + String: ['exists', 'dne', 'eq', 'neq', 'starts', 'ends', 'stringContainsString', 'matches', 'unique', 'isNull'], + Date: ['exists', 'dne', 'before', 'onOrBefore', 'after', 'onOrAfter', 'unique', 'isNull'], Object: [ 'exists', 'dne', diff --git a/src/lib/queryFromFilters.js b/src/lib/queryFromFilters.js index 78aa2cb9e0..c6a1aade2c 100644 --- a/src/lib/queryFromFilters.js +++ b/src/lib/queryFromFilters.js @@ -63,9 +63,15 @@ function addConstraint(query, filter, className) { case 'before': query.lessThan(filter.get('field'), filter.get('compareTo')); break; + case 'onOrBefore': + query.lessThanOrEqualTo(filter.get('field'), filter.get('compareTo')); + break; case 'after': query.greaterThan(filter.get('field'), filter.get('compareTo')); break; + case 'onOrAfter': + query.greaterThanOrEqualTo(filter.get('field'), filter.get('compareTo')); + break; case 'containsString': case 'containsNumber': query.equalTo(filter.get('field'), filter.get('compareTo')); @@ -80,6 +86,9 @@ function addConstraint(query, filter, className) { case 'stringContainsString': query.matches(filter.get('field'), filter.get('compareTo'), 'i'); break; + case 'matches': + query.matches(filter.get('field'), String(filter.get('compareTo')), filter.get('modifiers')); + break; case 'keyExists': query.exists(filter.get('field') + '.' + filter.get('compareTo')); break; From 40e65d2b39d100e57e9653e5b47592d837fc6c68 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 23:28:02 -0300 Subject: [PATCH 06/68] =?UTF-8?q?Fix=20sele=C3=A7=C3=A3o=20m=C3=BAltipla?= =?UTF-8?q?=20de=20c=C3=A9lulas=20no=20production2=20(shift-click=20sem=20?= =?UTF-8?q?destaque)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O BrowserCell do production2 importa B4aBrowserCell.scss, que NAO tinha as classes .selected/.leftBorder/.rightBorder/.topBorder/.bottomBorder. A logica de selecao (handleCellClick + selectedCells) ja funcionava, mas classes.push(styles.selected) empurrava undefined -> celula selecionava internamente mas nao pintava (nenhum erro no console). Copiadas as 5 regras do BrowserCell.scss normal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BrowserCell/B4aBrowserCell.scss | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/components/BrowserCell/B4aBrowserCell.scss b/src/components/BrowserCell/B4aBrowserCell.scss index ca693b4951..e992500a08 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: #e3effd; +} + +.leftBorder { + position: relative; + + &:after { + position: absolute; + pointer-events: none; + content: ''; + border-left: 2px solid #555572; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + +.rightBorder { + position: relative; + + &:after { + position: absolute; + pointer-events: none; + content: ''; + border-right: 2px solid #555572; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + +.topBorder { + position: relative; + + &:after { + position: absolute; + pointer-events: none; + content: ''; + border-top: 2px solid #555572; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + +.bottomBorder { + position: relative; + + &:after { + position: absolute; + pointer-events: none; + content: ''; + border-bottom: 2px solid #555572; + top: 0; + left: 0; + right: 0; + bottom: 0; + } +} + // .readonly { // color: #353446; // opacity: .9; From 8114c22bd430a5a02944f006ff383dee44a3e07e Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 23:34:24 -0300 Subject: [PATCH 07/68] Data Browser: copia do intervalo (Cmd+C) + cor de selecao pro tema escuro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Cmd/Ctrl+C agora copia o INTERVALO de celulas selecionadas (tab/newline), nao so a celula focada. No production2 o range faz setCurrent(null) -> copyableValue undefined -> o case 67 existente nao copiava nada. Adicionado o copy multi-celula no topo do handleKey (guard >= 0 pra nao quebrar no estado inicial). - .selected: de #e3effd (claro) para rgba(22,105,252,.3) e bordas #1669fc — legivel no Data Browser escuro do production2 (antes o texto claro sumia no fundo azul-claro). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../BrowserCell/B4aBrowserCell.scss | 10 +++--- .../Data/Browser/DataBrowser.react.js | 33 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/components/BrowserCell/B4aBrowserCell.scss b/src/components/BrowserCell/B4aBrowserCell.scss index e992500a08..32eee847ec 100644 --- a/src/components/BrowserCell/B4aBrowserCell.scss +++ b/src/components/BrowserCell/B4aBrowserCell.scss @@ -49,7 +49,7 @@ // Multi-cell (shift-click) selection highlight. The B4a BrowserCell references // these classes; without them the selection had no visual feedback. .selected { - background-color: #e3effd; + background-color: rgba(22, 105, 252, 0.3); } .leftBorder { @@ -59,7 +59,7 @@ position: absolute; pointer-events: none; content: ''; - border-left: 2px solid #555572; + border-left: 2px solid #1669fc; top: 0; left: 0; right: 0; @@ -74,7 +74,7 @@ position: absolute; pointer-events: none; content: ''; - border-right: 2px solid #555572; + border-right: 2px solid #1669fc; top: 0; left: 0; right: 0; @@ -89,7 +89,7 @@ position: absolute; pointer-events: none; content: ''; - border-top: 2px solid #555572; + border-top: 2px solid #1669fc; top: 0; left: 0; right: 0; @@ -104,7 +104,7 @@ position: absolute; pointer-events: none; content: ''; - border-bottom: 2px solid #555572; + border-bottom: 2px solid #1669fc; top: 0; left: 0; right: 0; diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 78ae58906f..2557fb0733 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -163,6 +163,39 @@ export default class DataBrowser extends React.Component { if (this.props.disableKeyControls) { return; } + // Cmd/Ctrl+C copies the whole selected cell range (tab/newline separated), + // not just the focused cell. + if (e.keyCode === 67 && (e.ctrlKey || e.metaKey)) { + const { rowStart, rowEnd, colStart, colEnd } = this.state.selectedCells || {}; + if (rowStart >= 0 && rowEnd >= 0 && colStart >= 0 && colEnd >= 0) { + let copyableValue = ''; + for (let rowIndex = rowStart; rowIndex <= rowEnd; rowIndex++) { + const rowData = []; + for (let colIndex = colStart; colIndex <= colEnd; colIndex++) { + const field = this.state.order[colIndex].name; + const value = field === 'objectId' + ? this.props.data[rowIndex].id + : this.props.data[rowIndex].attributes[field]; + if (typeof value === 'number' && !isNaN(value)) { + rowData.push(String(value)); + } else { + rowData.push(value || ''); + } + } + copyableValue += rowData.join('\t'); + if (rowIndex < rowEnd) { + copyableValue += '\r\n'; + } + } + this.setCopyableValue(copyableValue); + copy(copyableValue); + if (this.props.showNote) { + this.props.showNote('Value copied to clipboard', false); + } + e.preventDefault(); + return; + } + } if ( this.state.editing && this.state.current && From 3f37946e1df05c647f917577d6f741174fa26ad0 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 23:39:36 -0300 Subject: [PATCH 08/68] =?UTF-8?q?Data=20Browser:=20tecla=20espa=C3=A7o=20s?= =?UTF-8?q?eleciona/deseleciona=20a=20linha=20atual=20(case=2032)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portado da production1: no handleKey, case 32 (Space) faz toggle do selectRow da linha "current" (quando nao esta editando). production2 nao tinha esse case. Guards para this.props.selectRow/selection/data existirem. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Browser/DataBrowser.react.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/dashboard/Data/Browser/DataBrowser.react.js b/src/dashboard/Data/Browser/DataBrowser.react.js index 2557fb0733..6b99b1c6a8 100644 --- a/src/dashboard/Data/Browser/DataBrowser.react.js +++ b/src/dashboard/Data/Browser/DataBrowser.react.js @@ -313,6 +313,19 @@ export default class DataBrowser extends React.Component { }); e.preventDefault(); break; + case 32: // Space - toggle selection of the current row + if ( + !this.state.editing && + this.state.current?.row >= 0 && + this.props.selectRow && + this.props.data[this.state.current.row] + ) { + const rowId = this.props.data[this.state.current.row].id; + const isSelected = this.props.selection && this.props.selection[rowId]; + this.props.selectRow(rowId, !isSelected); + e.preventDefault(); + } + break; case 67: // C if ((e.ctrlKey || e.metaKey) && this.state.copyableValue !== undefined) { copy(this.state.copyableValue); // Copies current cell value to clipboard From c269227605555122b8b780cf339a3fea127dd067 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 23:46:22 -0300 Subject: [PATCH 09/68] Data Browser: checkbox de linha controlado (!! em selection) para o clique pegar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O checkbox usava checked={selection['*'] || selection[obj.id]} sem coercao — quando selection[id] era undefined, o input virava NAO-controlado, e a transicao nao-controlado->controlado no clique fazia o React nao registrar a marcacao. Coercao com !! (igual production1) mantem o input sempre controlado. O espaco ja funcionava porque ia direto no selectRow. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/BrowserRow/BrowserRow.react.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/BrowserRow/BrowserRow.react.js b/src/components/BrowserRow/BrowserRow.react.js index f708c4b6e3..087a59c98d 100644 --- a/src/components/BrowserRow/BrowserRow.react.js +++ b/src/components/BrowserRow/BrowserRow.react.js @@ -70,7 +70,7 @@ export default class BrowserRow extends Component { selectRow(obj.id, e.target.checked)} /> From ca15eb0f77167dda3caef81e679060363c218864 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Fri, 21 Aug 2026 23:55:06 -0300 Subject: [PATCH 10/68] =?UTF-8?q?Data=20Browser:=20sele=C3=A7=C3=A3o=20de?= =?UTF-8?q?=20m=C3=BAltiplas=20linhas=20por=20arrasto=20nos=20checkboxes?= =?UTF-8?q?=20(upstream)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Porta a feature drag-to-select da production1: mousedown num checkbox inicia o arrasto, cada checkbox que o mouse passa por cima marca/desmarca, mouseup encerra. - Browser.react: estado rowCheckboxDragging/draggedRowSelection, metodos onMouseDownRowCheckBox/onMouseUpRowCheckBox/onMouseOverRowCheckBox, listener global de mouseup, e passa os handlers pro DataBrowser (fluem via ...other ao BrowserTable). - BrowserTable: repassa onMouseDown/OverRowCheckBox aos 3 . - BrowserRow: checkbox com onMouseDown, checkCell com onMouseOver. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/BrowserRow/BrowserRow.react.js | 8 ++++- src/dashboard/Data/Browser/Browser.react.js | 36 ++++++++++++++++++- .../Data/Browser/BrowserTable.react.js | 6 ++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/components/BrowserRow/BrowserRow.react.js b/src/components/BrowserRow/BrowserRow.react.js index 087a59c98d..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/dashboard/Data/Browser/Browser.react.js b/src/dashboard/Data/Browser/Browser.react.js index f6bca77d09..7547c736ea 100644 --- a/src/dashboard/Data/Browser/Browser.react.js +++ b/src/dashboard/Data/Browser/Browser.react.js @@ -111,6 +111,8 @@ class Browser extends DashboardView { filters: new List(), ordering: '-createdAt', selection: {}, + rowCheckboxDragging: false, + draggedRowSelection: false, uniqueClassFields: new List(), exporting: false, exportingCount: 0, @@ -180,6 +182,9 @@ class Browser extends DashboardView { this.showCreateClass = this.showCreateClass.bind(this); this.refresh = this.refresh.bind(this); this.selectRow = this.selectRow.bind(this); + this.onMouseDownRowCheckBox = this.onMouseDownRowCheckBox.bind(this); + this.onMouseUpRowCheckBox = this.onMouseUpRowCheckBox.bind(this); + this.onMouseOverRowCheckBox = this.onMouseOverRowCheckBox.bind(this); this.updateRow = this.updateRow.bind(this); this.updateOrdering = this.updateOrdering.bind(this); this.handlePointerClick = this.handlePointerClick.bind(this); @@ -284,7 +289,9 @@ class Browser extends DashboardView { window.addEventListener('resize', this.windowResizeHandler); } - async componentDidMount() { + async componentDidMount() { + // End row-checkbox drag selection when the mouse is released anywhere. + window.addEventListener('mouseup', this.onMouseUpRowCheckBox); this.addLocation(this.props.params.appId); try { await this.props.schema.dispatch(ActionTypes.FETCH); @@ -319,9 +326,34 @@ class Browser extends DashboardView { componentWillUnmount() { + window.removeEventListener('mouseup', this.onMouseUpRowCheckBox); this.removeLocation(); } + // Drag over the row checkboxes (mousedown on one, drag over others) to select + // a range of rows, matching the upstream Data Browser behavior. + onMouseDownRowCheckBox(checked) { + this.setState({ + rowCheckboxDragging: true, + draggedRowSelection: !checked, + }); + } + + onMouseUpRowCheckBox() { + if (this.state.rowCheckboxDragging) { + this.setState({ + rowCheckboxDragging: false, + draggedRowSelection: false, + }); + } + } + + onMouseOverRowCheckBox(id) { + if (this.state.rowCheckboxDragging) { + this.selectRow(id, this.state.draggedRowSelection); + } + } + componentWillReceiveProps(nextProps, nextContext) { if (nextProps.params.appId !== this.props.params.appId) { this.removeLocation(); @@ -2610,6 +2642,8 @@ class Browser extends DashboardView { maxFetched={this.state.lastMax} selectRow={this.selectRow} selection={this.state.selection} + onMouseDownRowCheckBox={this.onMouseDownRowCheckBox} + onMouseOverRowCheckBox={this.onMouseOverRowCheckBox} data={this.state.data} ordering={this.state.ordering} newObject={this.state.newObject} diff --git a/src/dashboard/Data/Browser/BrowserTable.react.js b/src/dashboard/Data/Browser/BrowserTable.react.js index 71a54c879d..92f4c6e481 100644 --- a/src/dashboard/Data/Browser/BrowserTable.react.js +++ b/src/dashboard/Data/Browser/BrowserTable.react.js @@ -159,6 +159,8 @@ export default class BrowserTable extends React.Component { rowWidth={rowWidth} selection={this.props.selection} selectRow={this.props.selectRow} + onMouseDownRowCheckBox={this.props.onMouseDownRowCheckBox} + onMouseOverRowCheckBox={this.props.onMouseOverRowCheckBox} setCurrent={this.props.setCurrent} setEditing={this.props.setEditing} setRelation={this.props.setRelation} @@ -231,6 +233,8 @@ export default class BrowserTable extends React.Component { rowWidth={rowWidth} selection={this.props.selection} selectRow={this.props.selectRow} + onMouseDownRowCheckBox={this.props.onMouseDownRowCheckBox} + onMouseOverRowCheckBox={this.props.onMouseOverRowCheckBox} setCurrent={this.props.setCurrent} setEditing={this.props.setEditing} setRelation={this.props.setRelation} @@ -313,6 +317,8 @@ export default class BrowserTable extends React.Component { rowWidth={rowWidth} selection={this.props.selection} selectRow={this.props.selectRow} + onMouseDownRowCheckBox={this.props.onMouseDownRowCheckBox} + onMouseOverRowCheckBox={this.props.onMouseOverRowCheckBox} setCurrent={this.props.setCurrent} setEditing={this.props.setEditing} setRelation={this.props.setRelation} From 16236234eefdeb5fbb3e7763ce807b22f172b822 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Sat, 22 Aug 2026 08:41:39 -0300 Subject: [PATCH 11/68] Agent: salva a apiKey na env var do app (Cloud Code), fora do localStorage Agora que o pre-production2 tem getEnvVars/updateEnvVars (feature do production2): - Configure -> Save: grava a apiKey na env var OPENAI_API_KEY do app (merge com as existentes via getEnvVars); dispara rebuild do app (comportamento da feature). - Agent carrega: le a apiKey via getEnvVars; partes nao-secretas (name/provider/model) ficam em localStorage. A chave nunca persiste no disco do browser. - Dialog: onSubmit retorna a promise -> modal mostra "Saving..." durante o rebuild e surface erros. Descricao do campo API Key atualizada. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 71 +++++++++++++++---- .../Data/Agent/AgentConfigDialog.react.js | 23 +++--- 2 files changed, 70 insertions(+), 24 deletions(-) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index 92c487920b..a79470aabe 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -20,6 +20,10 @@ 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'; + @withRouter class Agent extends DashboardView { static contextType = CurrentApp; @@ -96,21 +100,65 @@ class Agent extends DashboardView { } } - saveAgentConfig = (model) => { - const config = { models: [model] }; + // Save the model config. The API key is stored in the app environment variable + // (Cloud Code env) — NOT in localStorage. Non-secret parts (name/provider/model) + // are cached in localStorage so we know how to build the request. Returns a + // promise so the dialog can show "Saving…" and surface errors while the app + // rebuilds. + saveAgentConfig = async (model) => { + const { name, provider, model: modelId, apiKey } = model; + + // 1. Store the API key as an app env var, merging with the existing ones. + if (apiKey && this.context && this.context.getEnvVars && this.context.updateEnvVars) { + const existing = await this.context.getEnvVars(); + const envVars = (existing && existing.envVars) || {}; + await this.context.updateEnvVars({ ...envVars, [AGENT_ENV_KEY]: apiKey }); + } + + // 2. Cache non-secret parts locally (no API key). const key = this.agentConfigStorageKey(); if (key) { try { - localStorage.setItem(key, JSON.stringify(config)); + localStorage.setItem( + key, + JSON.stringify({ models: [{ name, provider, model: modelId }] }) + ); } catch (error) { - console.warn('Failed to save agent config:', error); + console.warn('Failed to cache agent config:', error); } } - this.setState({ userAgentConfig: config, showConfigDialog: false }, () => { - this.setSelectedModel(model.name); + + // 3. Keep the full config (incl. key) in memory for this session. + this.setState({ userAgentConfig: { models: [model] } }, () => { + this.setSelectedModel(name); }); } + // Load the effective config: non-secret parts from localStorage + the API key + // from the app environment variable. + async loadAgentConfig() { + const local = this.getStoredAgentConfig(); + let apiKey; + try { + if (this.context && this.context.getEnvVars) { + const existing = await this.context.getEnvVars(); + apiKey = existing && existing.envVars && existing.envVars[AGENT_ENV_KEY]; + } + } catch (error) { + console.warn('Failed to read agent API key from env vars:', error); + } + + if (local && local.models[0] && apiKey) { + const m = local.models[0]; + const config = { + models: [{ name: m.name, provider: m.provider || 'openai', model: m.model, apiKey }], + }; + this.setState({ userAgentConfig: config }, () => this.setDefaultModel()); + } else { + this.setDefaultModel(); + } + } + // Effective config: user-provided (localStorage) takes precedence over the // dashboard config file (props.agentConfig). getAgentConfig() { @@ -184,14 +232,9 @@ class Agent extends DashboardView { this.setState({ route: 'agent' }); } - // Load user-provided agent config (from the Configure dialog) now that - // the app context (slug) is available. - const storedAgentConfig = this.getStoredAgentConfig(); - if (storedAgentConfig) { - this.setState({ userAgentConfig: storedAgentConfig }, () => this.setDefaultModel()); - } else { - this.setDefaultModel(); - } + // 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.loadAgentConfig(); // Load saved chat state after component mounts when context is available this.loadSavedChatState(); diff --git a/src/dashboard/Data/Agent/AgentConfigDialog.react.js b/src/dashboard/Data/Agent/AgentConfigDialog.react.js index 176516343e..703e017bf5 100644 --- a/src/dashboard/Data/Agent/AgentConfigDialog.react.js +++ b/src/dashboard/Data/Agent/AgentConfigDialog.react.js @@ -67,15 +67,18 @@ export default class AgentConfigDialog extends React.Component { enabled={this.valid()} clearFields={this.clearFields} onClose={this.props.onClose} - onSubmit={() => { - this.props.onConfirm({ - name: this.state.name.trim(), - provider: 'openai', - model: this.state.model.trim(), - apiKey: this.state.apiKey.trim(), - }); - return Promise.resolve(); - }} + onSubmit={() => + // Returns a promise so the modal shows "Saving…" while the API key is + // written to the app env var (which triggers an app rebuild). + Promise.resolve( + this.props.onConfirm({ + name: this.state.name.trim(), + provider: 'openai', + model: this.state.model.trim(), + apiKey: this.state.apiKey.trim(), + }) + ) + } > } @@ -113,7 +116,7 @@ export default class AgentConfigDialog extends React.Component { } /> } + label={
); } - 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 (
From 18874a2712a011d6b43a2b5c1098dce779fd37f5 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Sat, 22 Aug 2026 18:08:59 -0300 Subject: [PATCH 22/68] =?UTF-8?q?Agent:=20mostra=20modelo=20ativo=20no=20t?= =?UTF-8?q?oolbar=20+=20sele=C3=A7=C3=A3o=20de=20modelo=20por-app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rotulo no toolbar com o nome do modelo selecionado (title mostra o model id, ex. gpt-4o) — dá pra ver qual está ativo sem abrir o menu. - selectedAgentModel deixa de ser global e passa a ser por-app (selectedAgentModel_); setDefaultModel respeita a escolha salva do app ou cai no primeiro modelo. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 29 ++++++++++++++++++++----- src/dashboard/Data/Agent/Agent.scss | 10 +++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index a224ace913..b1b5c5c64e 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -53,9 +53,14 @@ class Agent extends DashboardView { this.action = new SidebarAction('Clear Chat', () => this.clearChat()); } + selectedModelStorageKey() { + const appSlug = this.context ? this.context.slug : null; + return appSlug ? `selectedAgentModel_${appSlug}` : null; + } + getStoredSelectedModel() { - const stored = localStorage.getItem('selectedAgentModel'); - return stored; + const key = this.selectedModelStorageKey(); + return key ? localStorage.getItem(key) : null; } getStoredPermissions() { @@ -296,19 +301,25 @@ class Agent extends DashboardView { } setDefaultModel() { - // Set default selected model if none is selected and models are available + // 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) { - this.setSelectedModel(models[0].name); + 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 }); - localStorage.setItem('selectedAgentModel', modelName); + const key = this.selectedModelStorageKey(); + if (key) { + localStorage.setItem(key, modelName); + } } scrollToBottom() { @@ -525,6 +536,14 @@ class Agent extends DashboardView { ))} )} + {selectedModel && ( + m.name === selectedModel) || {}).model || ''}`} + > + {selectedModel} + + )} Date: Mon, 24 Aug 2026 09:26:31 -0300 Subject: [PATCH 23/68] Agent: reseta modelo/config/chat ao trocar de app (evita vazamento entre apps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O componente Agent é reaproveitado ao navegar entre apps (só o contexto/slug muda, não há remount), então selectedModel/userAgentConfig/messages ficavam com os valores do app anterior. Agora detecta a troca de slug em componentDidUpdate e recarrega a partir do storage do app novo. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index b1b5c5c64e..d1dc55e638 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -251,6 +251,7 @@ class Agent extends DashboardView { // 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 @@ -279,6 +280,23 @@ class Agent extends DashboardView { } 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(); From d6d4f15a1749d223fcd4a411fbc0d18474890352 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 09:53:15 -0300 Subject: [PATCH 24/68] =?UTF-8?q?Agent:=20guarda=20a=20lista=20de=20modelo?= =?UTF-8?q?s=20como=20env=20var=20(AGENT=5FMODELS),=20n=C3=A3o=20localStor?= =?UTF-8?q?age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Antes só a API key era env var; a lista de modelos ficava em localStorage (agentUserConfig_), o que fazia o display name vazar entre apps. Agora a config inteira (key em OPENAI_API_KEY + lista em AGENT_MODELS) é env var, então o backend é a única fonte por-app. Também guarda o render do modelo ativo no toolbar para só mostrar quando o modelo existe na lista do app atual. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 86 +++++++++++-------------- 1 file changed, 37 insertions(+), 49 deletions(-) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index d1dc55e638..435ea2de71 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -23,6 +23,9 @@ 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. +const AGENT_MODELS_ENV_KEY = 'AGENT_MODELS'; @withRouter class Agent extends DashboardView { @@ -85,35 +88,12 @@ class Agent extends DashboardView { } } - agentConfigStorageKey() { - const appSlug = this.context ? this.context.slug : null; - return appSlug ? `agentUserConfig_${appSlug}` : null; - } - - getStoredAgentConfig() { - try { - const key = this.agentConfigStorageKey(); - if (!key) { return null; } - const stored = localStorage.getItem(key); - if (!stored) { return null; } - const parsed = JSON.parse(stored); - if (!parsed || !Array.isArray(parsed.models) || parsed.models.length === 0) { return null; } - return parsed; - } catch (error) { - console.warn('Failed to parse stored agent config:', error); - return null; - } - } - - // Save the model config. The API key is stored in the app environment variable - // (Cloud Code env) — NOT in localStorage. Non-secret parts (name/provider/model) - // are cached in localStorage so we know how to build the request. Returns a + // 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. - // Save the config: a shared API key + a list of models. The key is stored in - // the app env var (Cloud Code env) — NOT in localStorage. The model list - // (non-secret) is cached in localStorage. 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, @@ -121,24 +101,19 @@ class Agent extends DashboardView { model: m.model, })); - // 1. Store the shared API key as an app env var, merging with the existing ones. - if (apiKey && this.context && this.context.getEnvVars && this.context.updateEnvVars) { + // Store the shared API key AND the model list as app env vars, merging with + // the existing ones. + if (this.context && this.context.getEnvVars && this.context.updateEnvVars) { const existing = await this.context.getEnvVars(); const envVars = (existing && existing.envVars) || {}; - await this.context.updateEnvVars({ ...envVars, [AGENT_ENV_KEY]: apiKey }); - } - - // 2. Cache the (non-secret) model list locally. - const key = this.agentConfigStorageKey(); - if (key) { - try { - localStorage.setItem(key, JSON.stringify({ models: cleanModels })); - } catch (error) { - console.warn('Failed to cache agent config:', error); + const next = { ...envVars, [AGENT_MODELS_ENV_KEY]: JSON.stringify(cleanModels) }; + if (apiKey) { + next[AGENT_ENV_KEY] = apiKey; } + await this.context.updateEnvVars(next); } - // 3. Keep the full config (each model carries the shared key) in memory. + // 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]) { @@ -147,23 +122,36 @@ class Agent extends DashboardView { }); } - // Load the effective config: the model list from localStorage + the shared API - // key from the app environment variable. + // 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() { - const local = this.getStoredAgentConfig(); let apiKey; + let models = []; try { if (this.context && this.context.getEnvVars) { const existing = await this.context.getEnvVars(); - apiKey = existing && existing.envVars && existing.envVars[AGENT_ENV_KEY]; + const envVars = (existing && existing.envVars) || {}; + apiKey = envVars[AGENT_ENV_KEY]; + const rawModels = envVars[AGENT_MODELS_ENV_KEY]; + if (rawModels) { + try { + const parsed = JSON.parse(rawModels); + if (Array.isArray(parsed)) { + models = parsed; + } + } catch (error) { + console.warn('Failed to parse AGENT_MODELS env var:', error); + } + } } } catch (error) { - console.warn('Failed to read agent API key from env vars:', error); + console.warn('Failed to read agent config from env vars:', error); } - if (local && Array.isArray(local.models) && local.models.length > 0 && apiKey) { + if (models.length > 0 && apiKey) { const config = { - models: local.models.map(m => ({ + models: models.map(m => ({ name: m.name, provider: m.provider || 'openai', model: m.model, @@ -172,7 +160,7 @@ class Agent extends DashboardView { }; this.setState({ userAgentConfig: config }, () => this.setDefaultModel()); } else { - this.setDefaultModel(); + this.setState({ userAgentConfig: null }, () => this.setDefaultModel()); } } @@ -554,7 +542,7 @@ class Agent extends DashboardView { ))} )} - {selectedModel && ( + {selectedModel && models.some(m => m.name === selectedModel) && ( m.name === selectedModel) || {}).model || ''}`} From 8dcd29beb0a412a232a608eec0d7001fbd0418e6 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 10:13:00 -0300 Subject: [PATCH 25/68] =?UTF-8?q?Agent:=20nome=20do=20modelo=20opcional=20?= =?UTF-8?q?(sem=20"My=20model"=20default)=20+=20op=C3=A7=C3=A3o=20de=20del?= =?UTF-8?q?etar=20o=20agent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove o default "My model": o campo de display name começa vazio e, se ficar vazio, cai no id do modelo (ex. gpt-4). Assim nada aparece no toolbar antes de o usuário salvar um modelo de verdade. - Adiciona "Delete agent" (danger zone) no dialog: remove os env vars OPENAI_API_KEY e AGENT_MODELS, limpa a seleção por-app e volta ao empty state. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 25 ++++++++++++++++ .../Data/Agent/AgentConfigDialog.react.js | 30 ++++++++++++++++--- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index 435ea2de71..e2ab0fc107 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -122,6 +122,30 @@ class Agent extends DashboardView { }); } + // 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 () => { + if (this.context && this.context.getEnvVars && this.context.updateEnvVars) { + const existing = await this.context.getEnvVars(); + const envVars = { ...((existing && existing.envVars) || {}) }; + delete envVars[AGENT_ENV_KEY]; + delete envVars[AGENT_MODELS_ENV_KEY]; + await this.context.updateEnvVars(envVars); + } + + 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. @@ -751,6 +775,7 @@ class Agent extends DashboardView { initialModels={this.getAgentConfig()?.models || []} initialApiKey={(this.getAgentConfig()?.models || [])[0]?.apiKey} onConfirm={this.saveAgentConfig} + onDelete={this.deleteAgentConfig} onClose={() => this.setState({ showConfigDialog: false })} />
diff --git a/src/dashboard/Data/Agent/AgentConfigDialog.react.js b/src/dashboard/Data/Agent/AgentConfigDialog.react.js index 091055b8b7..612cd14d7b 100644 --- a/src/dashboard/Data/Agent/AgentConfigDialog.react.js +++ b/src/dashboard/Data/Agent/AgentConfigDialog.react.js @@ -30,7 +30,7 @@ export default class AgentConfigDialog extends React.Component { const initial = this.props.initialModels || []; const models = initial.length ? initial.map(m => ({ name: m.name || '', model: m.model || '' })) - : [{ name: 'My model', model: '' }]; + : [emptyModel()]; return { apiKey: this.props.initialApiKey || '', models }; } @@ -59,10 +59,12 @@ export default class AgentConfigDialog extends React.Component { } 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.name.trim() !== '' && m.model.trim() !== '') + this.state.models.every(m => m.model.trim() !== '') ); } @@ -83,7 +85,7 @@ export default class AgentConfigDialog extends React.Component { this.props.onConfirm({ apiKey: this.state.apiKey.trim(), models: this.state.models.map(m => ({ - name: m.name.trim(), + name: m.name.trim() || m.model.trim(), provider: 'openai', model: m.model.trim(), })), @@ -144,7 +146,7 @@ export default class AgentConfigDialog extends React.Component {
this.updateModel(i, 'name', value)} /> @@ -158,6 +160,26 @@ export default class AgentConfigDialog extends React.Component { } /> ))} + {this.props.onDelete && this.props.initialApiKey ? ( + } + input={ + + } + /> + ) : null} ); } From fa0ba8165dabf75ce8199352ee7eaa40e8803397 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 12:20:35 -0300 Subject: [PATCH 26/68] Agent: chama o endpoint server-side da API (chave nunca sai do backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Em vez de POST /apps/:id/agent no servidor do dashboard (mandando a apiKey no corpo), o frontend agora chama POST /parse-app/:slug/agent na API back4app via ParseApp.sendAgentMessage(). A chave OpenAI é lida da env var do app no backend; o browser não envia mais segredo. O agent server-side é stateless, então o histórico recente é enviado a cada request. validateModelConfig não exige mais apiKey no cliente. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 20 ++++--- src/lib/AgentService.js | 73 +++++++++---------------- src/lib/ParseApp.js | 19 +++++++ 3 files changed, 59 insertions(+), 53 deletions(-) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index e2ab0fc107..2129b5afe5 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -461,19 +461,25 @@ class Agent extends DashboardView { // Validate model configuration AgentService.validateModelConfig(modelConfig); - // Get app slug from context - const appSlug = this.context ? this.context.slug : null; - if (!appSlug) { + // 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'); } - // Get response from AI service with conversation context + // 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, - appSlug, - this.state.conversationId, - this.state.permissions + this.context, + this.state.permissions, + history ); const aiMessage = { diff --git a/src/lib/AgentService.js b/src/lib/AgentService.js index 97a1855284..4991ee5a81 100644 --- a/src/lib/AgentService.js +++ b/src/lib/AgentService.js @@ -5,22 +5,25 @@ * 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 + * Service class for handling AI agent API requests. + * + * The request is sent to the back4app API (via the app context), which runs the + * agent server-side and reads the OpenAI key from the app's own env var. NO + * secret (API key) is sent from the browser here. */ export default class AgentService { /** - * Send a message to the configured AI model and get a response + * 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} modelConfig - The model configuration object (name/provider/model) + * @param {Object} app - The current app context (ParseApp) with sendAgentMessage() * @param {Object} permissions - Permission settings for operations - * @returns {Promise<{response: string, conversationId: string}>} The AI's response and conversation ID + * @param {Array} history - Recent conversation history [{role, content}] + * @returns {Promise<{response: string, conversationId: null}>} The AI's response */ - static async sendMessage(message, modelConfig, appSlug, conversationId = null, permissions = {}) { + static async sendMessage(message, modelConfig, app, permissions = {}, history = []) { if (!modelConfig) { throw new Error('Model configuration is required'); } @@ -31,47 +34,27 @@ export default class AgentService { throw new Error('Model name is required in model configuration'); } - if (!appSlug) { - throw new Error('App slug is required to send message to agent'); + if (!app || typeof app.sendAgentMessage !== 'function') { + throw new Error('App context is required to send message to agent'); } try { - const requestBody = { - message: message, - modelName: name - }; - - // If the model config carries its own credentials (provided by the user - // through the in-UI Configure dialog, not the dashboard config file), - // forward them so the server can use them instead of config.agent. - if (modelConfig.apiKey && modelConfig.provider && modelConfig.model) { - requestBody.modelConfig = { - name: modelConfig.name, - provider: modelConfig.provider, - model: modelConfig.model, - apiKey: modelConfig.apiKey, - }; - } - - // 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) { + const response = await app.sendAgentMessage({ + message, + modelName: name, + permissions: permissions || {}, + history: history || [], + }); + + if (response && response.error) { throw new Error(response.error); } return { response: response.response, - conversationId: response.conversationId + // The server-side agent is stateless (history is sent by the client), so + // there is no server conversation id. + conversationId: null, }; } catch (error) { // Handle specific error types @@ -103,7 +86,9 @@ export default class AgentService { throw new Error('Model configuration is required'); } - const { name, provider, model, apiKey } = modelConfig; + // The API key is NOT required client-side anymore: it lives in the app env + // var and is used server-side. We only validate the non-secret fields. + const { name, provider, model } = modelConfig; if (!name) { throw new Error('Model name is required in model configuration'); @@ -117,10 +102,6 @@ export default class AgentService { throw new Error('Model name is required in model configuration'); } - if (!apiKey) { - throw new Error('API key is required in model configuration'); - } - return true; } diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 5524a3fa63..481e40bf87 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -1944,6 +1944,25 @@ export default class ParseApp { } } + // Run the AI Agent server-side (back4app API). The OpenAI key is read from the + // app's own env var on the backend and never leaves it — nothing secret is sent + // from the browser here. `payload` = { message, modelName, permissions, history }. + async sendAgentMessage(payload) { + try { + return ( + await axios.post( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/agent`, + payload, + { withCredentials: true } + ) + ).data; + } catch (err) { + const apiError = err.response && err.response.data && err.response.data.error; + throw apiError ? new Error(apiError) : err; + } + } + async getOauth() { try { return ( From 55e3644a01706c7c0d5809c0718692d0339cf96a Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 12:40:48 -0300 Subject: [PATCH 27/68] =?UTF-8?q?Agent:=20modelo=20vira=20dropdown=20h?= =?UTF-8?q?=C3=ADbrido=20(lista=20curada=20+=20"Custom=E2=80=A6")?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Em vez de campo livre pro id do modelo, um dropdown com modelos OpenAI comuns (gpt-4o, o3, gpt-5, ...) + opção "Custom…" que revela um campo de texto pra digitar qualquer id (ex. release nova). Evita typo e modelo inexistente sem travar quem quer um modelo fora da lista. O flag `custom` é só de UI (não é persistido); Save continua exigindo um model id. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Data/Agent/AgentConfigDialog.react.js | 90 +++++++++++++++---- 1 file changed, 71 insertions(+), 19 deletions(-) diff --git a/src/dashboard/Data/Agent/AgentConfigDialog.react.js b/src/dashboard/Data/Agent/AgentConfigDialog.react.js index 612cd14d7b..a24279a0f7 100644 --- a/src/dashboard/Data/Agent/AgentConfigDialog.react.js +++ b/src/dashboard/Data/Agent/AgentConfigDialog.react.js @@ -6,19 +6,43 @@ * 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 cached in - * localStorage. Only OpenAI is supported for now, so provider is fixed. + * 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. */ -const emptyModel = () => ({ name: '', model: '' }); + +// 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) { @@ -28,9 +52,7 @@ export default class AgentConfigDialog extends React.Component { stateFromProps() { const initial = this.props.initialModels || []; - const models = initial.length - ? initial.map(m => ({ name: m.name || '', model: m.model || '' })) - : [emptyModel()]; + const models = initial.length ? initial.map(modelFromInitial) : [emptyModel()]; return { apiKey: this.props.initialApiKey || '', models }; } @@ -49,6 +71,18 @@ export default class AgentConfigDialog extends React.Component { 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()] }); }; @@ -143,19 +177,37 @@ export default class AgentConfigDialog extends React.Component { /> } input={ -
- this.updateModel(i, 'name', value)} - /> - this.updateModel(i, 'model', value)} - /> +
+
+ this.updateModel(i, 'name', value)} + /> +
+ this.selectModel(i, value)} + > + {[ + , + ...CURATED_MODELS.map(id => ( + + )), + , + ]} + +
+
+ {m.custom ? ( + this.updateModel(i, 'model', value)} + /> + ) : null}
} /> From f868a128ffac0c741bf064dd3f9c55cd5591a4f0 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 13:19:33 -0300 Subject: [PATCH 28/68] Agent: roda no container do app (Cloud Code) + provisiona ao salvar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O agent deixa de rodar na API compartilhada e passa a rodar dentro do container do app do cliente, como uma Cloud Function (dashboardAgent). Assim escala com os recursos do próprio app e a chave nunca sai do container. - Novo cloud/dashboard-agent/index.js (dashboard-agent.cloud.js): loop OpenAI + tools via Parse SDK (useMasterKey local), lê OPENAI_API_KEY/AGENT_MODELS do env, exige master key. Importado como texto cru (webpack ?raw / asset/source). - agentCloudProvisioning.js: injeta/atualiza o arquivo gerenciado (marcador @back4app-dashboard-agent) e o require no main.js sem sobrescrever código do cliente; aborta com erro claro em colisão de nome; remove no delete. - ParseApp.getCloudCode/saveCloudCode; saveAgentConfig instala o cloud code ao salvar (colisão aborta antes de qualquer write), deleteAgentConfig remove. - Dialog mostra aviso explícito de que salvar instala Cloud Code e redeploya. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 48 ++- .../Data/Agent/AgentConfigDialog.react.js | 7 + .../Data/Agent/agentCloudProvisioning.js | 143 +++++++ .../Data/Agent/cloud/dashboard-agent.cloud.js | 366 ++++++++++++++++++ src/lib/ParseApp.js | 39 +- webpack/base.config.js | 7 + 6 files changed, 598 insertions(+), 12 deletions(-) create mode 100644 src/dashboard/Data/Agent/agentCloudProvisioning.js create mode 100644 src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index 2129b5afe5..cac0315cfb 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -16,6 +16,7 @@ import SidebarAction from 'components/Sidebar/SidebarAction'; import Toolbar from 'components/Toolbar/Toolbar.react'; import AgentService from 'lib/AgentService'; import AgentConfigDialog from './AgentConfigDialog.react'; +import { injectAgent, removeAgent } from './agentCloudProvisioning'; import styles from './Agent.scss'; import { withRouter } from 'lib/withRouter'; import { CurrentApp } from 'context/currentApp'; @@ -100,20 +101,35 @@ class Agent extends DashboardView { 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) || []); + } - // Store the shared API key AND the model list as app env vars, merging with - // the existing ones. - if (this.context && this.context.getEnvVars && this.context.updateEnvVars) { - const existing = await this.context.getEnvVars(); + // 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]: JSON.stringify(cleanModels) }; if (apiKey) { next[AGENT_ENV_KEY] = apiKey; } - await this.context.updateEnvVars(next); + await app.updateEnvVars(next); + } + + // 3. Deploy the agent Cloud Function into the app's container. + if (newTree && app && app.saveCloudCode) { + await app.saveCloudCode(newTree); } - // Keep the full config (each model carries the shared key) in memory. + // 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]) { @@ -126,12 +142,26 @@ class Agent extends DashboardView { // 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 () => { - if (this.context && this.context.getEnvVars && this.context.updateEnvVars) { - const existing = await this.context.getEnvVars(); + 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 this.context.updateEnvVars(envVars); + 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(); diff --git a/src/dashboard/Data/Agent/AgentConfigDialog.react.js b/src/dashboard/Data/Agent/AgentConfigDialog.react.js index a24279a0f7..e1205c01bb 100644 --- a/src/dashboard/Data/Agent/AgentConfigDialog.react.js +++ b/src/dashboard/Data/Agent/AgentConfigDialog.react.js @@ -127,6 +127,13 @@ export default class AgentConfigDialog extends React.Component { ) } > +
+ 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. +
} input={ {}} />} diff --git a/src/dashboard/Data/Agent/agentCloudProvisioning.js b/src/dashboard/Data/Agent/agentCloudProvisioning.js new file mode 100644 index 0000000000..7ac253c55d --- /dev/null +++ b/src/dashboard/Data/Agent/agentCloudProvisioning.js @@ -0,0 +1,143 @@ +/* + * 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. + */ + +// Raw source of the Cloud Function that runs inside the app's container. +import AGENT_SOURCE from './cloud/dashboard-agent.cloud.js?raw'; + +// Marker embedded in the generated file, used to tell "our file" apart from a +// same-named file the customer may already have. +export const AGENT_MARKER = '@back4app-dashboard-agent'; + +const AGENT_DIR = 'dashboard-agent'; +const AGENT_FILE = 'index.js'; +const REQUIRE_SNIPPET = "require('./" + AGENT_DIR + "/" + AGENT_FILE + "');"; +const REQUIRE_MATCH = './' + AGENT_DIR + '/' + AGENT_FILE; + +export class CloudCollisionError extends Error { + constructor(message) { + super(message); + this.name = 'CloudCollisionError'; + this.code = 'CLOUD_COLLISION'; + } +} + +function findCloudFolder(tree) { + return (tree || []).find(node => node && node.text === 'cloud' && (node.type === 'folder' || Array.isArray(node.children))); +} + +function fileCode(node) { + return (node && node.data && typeof node.data.code === 'string') ? node.data.code : ''; +} + +// True if the app already has our agent file installed (marker present). +export function isAgentInstalled(tree) { + const cloud = findCloudFolder(tree); + if (!cloud || !Array.isArray(cloud.children)) { return false; } + const dir = cloud.children.find(n => n && n.text === AGENT_DIR); + if (!dir || !Array.isArray(dir.children)) { return false; } + const file = dir.children.find(n => n && n.text === AGENT_FILE); + return !!file && fileCode(file).indexOf(AGENT_MARKER) !== -1; +} + +// Ensure main.js contains the require() that loads our agent file (append only). +function ensureRequire(cloud) { + const main = cloud.children.find(n => n && n.text === 'main.js'); + if (!main) { + cloud.children.push({ text: 'main.js', data: { code: REQUIRE_SNIPPET + '\n' } }); + return; + } + const code = fileCode(main); + if (code.indexOf(REQUIRE_MATCH) === -1) { + const sep = code.length && !code.endsWith('\n') ? '\n' : ''; + main.data = main.data || {}; + main.data.code = code + sep + REQUIRE_SNIPPET + '\n'; + } +} + +/** + * Return a modified copy of the cloud tree with the agent file+folder installed + * (or updated to the latest source) and a require() ensured in main.js. + * + * Throws CloudCollisionError if a file/folder at cloud/dashboard-agent already + * exists WITHOUT our marker (i.e. it belongs to the customer) — we never + * overwrite code that isn't ours. + */ +export function injectAgent(tree) { + // Work on a deep copy so callers can decide whether to persist. + const next = JSON.parse(JSON.stringify(tree || [])); + let cloud = findCloudFolder(next); + if (!cloud) { + cloud = { text: 'cloud', type: 'folder', state: { opened: true }, children: [] }; + next.unshift(cloud); + } + if (!Array.isArray(cloud.children)) { cloud.children = []; } + + const existing = cloud.children.find(n => n && n.text === AGENT_DIR); + if (existing) { + // A non-folder node with our name → collision. + if (existing.type && existing.type !== 'folder') { + throw new CloudCollisionError('A file named "' + AGENT_DIR + '" already exists in your Cloud Code. Rename or remove it before configuring the AI Agent.'); + } + if (!Array.isArray(existing.children)) { existing.children = []; } + const file = existing.children.find(n => n && n.text === AGENT_FILE); + if (file && fileCode(file).indexOf(AGENT_MARKER) === -1) { + // Same-named file the customer created — do not clobber. + throw new CloudCollisionError('A file "' + AGENT_DIR + '/' + AGENT_FILE + '" already exists in your Cloud Code and is not managed by the dashboard. Rename or remove it before configuring the AI Agent.'); + } + if (file) { + file.data = { code: AGENT_SOURCE }; + } else { + existing.children.push({ text: AGENT_FILE, data: { code: AGENT_SOURCE } }); + } + } else { + cloud.children.push({ + text: AGENT_DIR, + type: 'folder', + state: { opened: false }, + children: [{ text: AGENT_FILE, data: { code: AGENT_SOURCE } }] + }); + } + + ensureRequire(cloud); + + // Strip helper flags the GET endpoint may have added. + next.forEach(node => { if (node) { delete node.mainJsNotExists; delete node.indexHtmlNotExists; } }); + return next; +} + +/** + * Return a modified copy of the tree with our agent file/folder removed and the + * require() line stripped from main.js. Only removes OUR file (marker-guarded). + */ +export function removeAgent(tree) { + const next = JSON.parse(JSON.stringify(tree || [])); + const cloud = findCloudFolder(next); + if (!cloud || !Array.isArray(cloud.children)) { return next; } + + const dir = cloud.children.find(n => n && n.text === AGENT_DIR); + if (dir && Array.isArray(dir.children)) { + const file = dir.children.find(n => n && n.text === AGENT_FILE); + // Only remove if it is our managed file. + if (file && fileCode(file).indexOf(AGENT_MARKER) !== -1) { + dir.children = dir.children.filter(n => n !== file); + if (dir.children.length === 0) { + cloud.children = cloud.children.filter(n => n !== dir); + } + } + } + + const main = cloud.children.find(n => n && n.text === 'main.js'); + if (main) { + const lines = fileCode(main).split('\n').filter(line => line.indexOf(REQUIRE_MATCH) === -1); + main.data = main.data || {}; + main.data.code = lines.join('\n'); + } + + next.forEach(node => { if (node) { delete node.mainJsNotExists; delete node.indexHtmlNotExists; } }); + return next; +} diff --git a/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js new file mode 100644 index 0000000000..fcaef13f4e --- /dev/null +++ b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js @@ -0,0 +1,366 @@ +// @back4app-dashboard-agent generated file — do not edit by hand. +// Managed by the Parse Dashboard "AI Agent" configuration. Editing or deleting +// this file will be overwritten the next time the agent config is saved. +/* eslint-disable */ +'use strict'; + +/** + * AI Agent — runs INSIDE this app's Cloud Code container. + * + * The dashboard invokes the `dashboardAgent` Cloud Function (with the master + * key). All heavy work (OpenAI calls + database operations) happens here, in the + * app's own container, using the app's own resources — NOT on the shared + * back4app API. The OpenAI key is read from this app's environment variable + * (OPENAI_API_KEY) and never leaves the container. + */ + +var OPENAI_URL = 'https://api.openai.com/v1/chat/completions'; +var OPENAI_TIMEOUT_MS = 60000; +var WRITE_OPERATIONS = ['deleteObject', 'deleteClass', 'updateObject', 'createObject', 'createClass']; +var REMOVABLE_PARAMS = ['reasoning_effort', 'temperature', 'top_p', 'frequency_penalty', 'presence_penalty', 'max_tokens', 'max_completion_tokens']; + +var 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' } + }, required: ['className'] } } }, + { type: 'function', function: { name: 'createObject', description: 'Create a new object in a Parse class/table. Write operation — requires explicit user confirmation. You MUST provide objectData with the field values.', 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/values as a JSON object.', additionalProperties: true }, + confirmed: { type: 'boolean', description: 'Must be true to confirm this write', default: false } + }, required: ['className', 'objectData', 'confirmed'] } } }, + { type: 'function', function: { name: 'updateObject', description: 'Update an existing object. Write operation — requires explicit user confirmation.', parameters: { type: 'object', properties: { + className: { type: 'string', description: '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 confirm this write', default: false } + }, required: ['className', 'objectId', 'objectData', 'confirmed'] } } }, + { type: 'function', function: { name: 'deleteObject', description: 'Delete a SINGLE object/row by objectId. Destructive — requires explicit user confirmation.', parameters: { type: 'object', properties: { + className: { type: 'string', description: 'The Parse class containing the object' }, + objectId: { type: 'string', description: 'The objectId of the object to delete' }, + confirmed: { type: 'boolean', description: 'Must be true to confirm this destructive operation', default: false } + }, required: ['className', 'objectId', 'confirmed'] } } }, + { type: 'function', function: { name: 'getSchema', description: 'Get schema information for Parse classes (read-only).', parameters: { type: 'object', properties: { + className: { type: 'string', description: 'The Parse class to get schema for (optional; omit for all)' } + } } } }, + { 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 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. Requires explicit user confirmation.', parameters: { type: 'object', properties: { + className: { type: 'string', description: 'The Parse class to create' }, + fields: { type: 'object', description: 'Fields as {name: type} (e.g. {"name":"String","age":"Number"})' }, + confirmed: { type: 'boolean', description: 'Must be true to confirm', default: false } + }, required: ['className', 'confirmed'] } } }, + { type: 'function', function: { name: 'deleteClass', description: 'Delete an ENTIRE Parse class/table and ALL its data. Highly destructive — requires explicit user confirmation.', parameters: { type: 'object', properties: { + className: { type: 'string', description: 'The Parse class/table to completely delete' }, + confirmed: { type: 'boolean', description: 'Must be true to confirm this highly destructive operation', default: false } + }, required: ['className', 'confirmed'] } } } +]; + +var SYSTEM_PROMPT = [ + 'You are an AI assistant integrated into Parse Dashboard, a data management interface for Parse Server applications.', + '', + 'You can query and modify the database via the provided function tools:', + '- Query classes/tables, get schema, and count objects (read-only, no confirmation needed)', + '- Create/update objects, delete individual objects, create classes, delete entire classes (ALL require explicit user confirmation)', + '', + 'CRITICAL SECURITY RULE FOR WRITE OPERATIONS:', + '- Any write (create, update, delete) MUST have explicit user confirmation in the conversation.', + '- Explain what you will do and ask for confirmation; only call the function with confirmed=true after the user agrees.', + '- Read operations (query, getSchema, count) can be performed immediately.', + '', + 'When creating/updating objects you MUST provide the objectData parameter with the actual field values.', + 'If a database function returns an error, include the full error message in your response.', + '', + 'Format responses using Markdown (bold, code, lists, tables, headers) for readability.' +].join('\n'); + +function normalizeFieldType(type) { + switch (String(type).toLowerCase()) { + case 'string': return 'String'; + case 'number': return 'Number'; + case 'boolean': return 'Boolean'; + case 'date': return 'Date'; + case 'array': return 'Array'; + case 'object': return 'Object'; + case 'geopoint': return 'GeoPoint'; + case 'file': return 'File'; + default: return 'String'; + } +} + +function applyConstraints(query, where) { + Object.keys(where || {}).forEach(function (key) { + var value = where[key]; + if (typeof value === 'object' && value !== null) { + Object.keys(value).forEach(function (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); + } + }); +} + +async function executeDatabaseFunction(functionName, args, operationLog, permissions) { + if (WRITE_OPERATIONS.indexOf(functionName) !== -1) { + var v = permissions && permissions[functionName]; + if (!(v === true || v === 'true')) { + throw new Error('Permission denied: the "' + functionName + '" operation is disabled in the permissions settings. Enable it in the Parse Dashboard Permissions menu to allow it.'); + } + } + + try { + switch (functionName) { + case 'queryClass': { + var className = args.className; + var query = new Parse.Query(className); + applyConstraints(query, args.where || {}); + query.limit(Math.min(args.limit || 100, 1000)); + if (args.skip) { query.skip(args.skip); } + if (args.order) { args.order.charAt(0) === '-' ? query.descending(args.order.substring(1)) : query.ascending(args.order); } + if (args.include && args.include.length) { query.include(args.include); } + if (args.select && args.select.length) { query.select(args.select); } + var results = await query.find({ useMasterKey: true }); + operationLog.push({ operation: 'queryClass', className: className, resultCount: results.length }); + return results.map(function (o) { return o.toJSON(); }); + } + case 'countObjects': { + var q = new Parse.Query(args.className); + applyConstraints(q, args.where || {}); + var count = await q.count({ useMasterKey: true }); + return { count: count }; + } + case 'createObject': { + if (!args.objectData || typeof args.objectData !== 'object' || Object.keys(args.objectData).length === 0) { + throw new Error("Missing or empty 'objectData'. Provide the fields/values as a JSON object."); + } + if (!args.confirmed) { throw new Error('Creating objects requires user confirmation.'); } + var Klass = Parse.Object.extend(args.className); + var obj = new Klass(); + Object.keys(args.objectData).forEach(function (k) { obj.set(k, args.objectData[k]); }); + var saved = await obj.save(null, { useMasterKey: true }); + operationLog.push({ operation: 'createObject', className: args.className }); + return saved.toJSON(); + } + case 'updateObject': { + if (!args.confirmed) { throw new Error('Updating objects requires user confirmation.'); } + var uq = new Parse.Query(args.className); + var uobj = await uq.get(args.objectId, { useMasterKey: true }); + Object.keys(args.objectData || {}).forEach(function (k) { uobj.set(k, args.objectData[k]); }); + var usaved = await uobj.save(null, { useMasterKey: true }); + operationLog.push({ operation: 'updateObject', className: args.className, objectId: args.objectId }); + return usaved.toJSON(); + } + case 'deleteObject': { + if (!args.confirmed) { throw new Error('Deleting objects requires user confirmation.'); } + var dq = new Parse.Query(args.className); + var dobj = await dq.get(args.objectId, { useMasterKey: true }); + await dobj.destroy({ useMasterKey: true }); + operationLog.push({ operation: 'deleteObject', className: args.className, objectId: args.objectId }); + return { success: true, objectId: args.objectId }; + } + case 'getSchema': { + if (args.className) { return await new Parse.Schema(args.className).get({ useMasterKey: true }); } + return await Parse.Schema.all({ useMasterKey: true }); + } + case 'createClass': { + if (!args.confirmed) { throw new Error('Creating classes requires user confirmation.'); } + var schema = new Parse.Schema(args.className); + var fields = args.fields || {}; + Object.keys(fields).forEach(function (name) { + var t = normalizeFieldType(fields[name]); + if (t === 'String') { schema.addString(name); } + else if (t === 'Number') { schema.addNumber(name); } + else if (t === 'Boolean') { schema.addBoolean(name); } + else if (t === 'Date') { schema.addDate(name); } + else if (t === 'Array') { schema.addArray(name); } + else if (t === 'Object') { schema.addObject(name); } + else if (t === 'GeoPoint') { schema.addGeoPoint(name); } + else if (t === 'File') { schema.addFile(name); } + else { schema.addString(name); } + }); + var savedSchema = await schema.save({ useMasterKey: true }); + operationLog.push({ operation: 'createClass', className: args.className }); + return { success: true, className: args.className, schema: savedSchema }; + } + case 'deleteClass': { + if (!args.confirmed) { throw new Error('Deleting classes requires user confirmation.'); } + try { await new Parse.Schema(args.className).get({ useMasterKey: true }); } + catch (e) { if (e.code === 103) { throw new Error('Class "' + args.className + '" does not exist.'); } throw e; } + var s = new Parse.Schema(args.className); + await s.purge({ useMasterKey: true }); + await s.delete({ useMasterKey: true }); + operationLog.push({ operation: 'deleteClass', className: args.className }); + return { success: true, className: args.className, message: 'Class "' + args.className + '" and all its data have been permanently deleted.' }; + } + default: + throw new Error('Unknown function: ' + functionName); + } + } catch (error) { + throw new Error('Database operation failed: ' + (error.message || String(error))); + } +} + +// HTTP helper: prefer global fetch (Node 18+), fall back to Parse.Cloud.httpRequest. +async function httpPostJson(url, headers, bodyObj, timeoutMs) { + if (typeof fetch === 'function') { + var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; + var timer = controller ? setTimeout(function () { controller.abort(); }, timeoutMs) : null; + try { + var resp = await fetch(url, { + method: 'POST', + headers: headers, + body: JSON.stringify(bodyObj), + signal: controller ? controller.signal : undefined + }); + var text = await resp.text(); + var data = null; + try { data = JSON.parse(text); } catch (e) { data = null; } + return { ok: resp.ok, status: resp.status, data: data }; + } finally { if (timer) { clearTimeout(timer); } } + } + try { + var r = await Parse.Cloud.httpRequest({ method: 'POST', url: url, headers: headers, body: JSON.stringify(bodyObj) }); + return { ok: r.status >= 200 && r.status < 300, status: r.status, data: r.data }; + } catch (e) { + return { ok: false, status: e.status || 500, data: e.data }; + } +} + +function adjustUnsupportedParam(body, errorObj, message) { + var next = Object.assign({}, body); + var msg = message || ''; + + if (/reasoning_effort/i.test(msg) && /none/i.test(msg) && next.reasoning_effort !== 'none') { + next.reasoning_effort = 'none'; + return next; + } + if (/max_tokens/.test(msg) && /max_completion_tokens/.test(msg)) { + if (('max_tokens' in next) && !('max_completion_tokens' in next)) { next.max_completion_tokens = next.max_tokens; delete next.max_tokens; return next; } + if (('max_completion_tokens' in next) && !('max_tokens' in next)) { next.max_tokens = next.max_completion_tokens; delete next.max_completion_tokens; return next; } + } + var param = errorObj && errorObj.param; + if (!param) { var quoted = msg.match(/'([a-zA-Z_][a-zA-Z0-9_.]*)'/); if (quoted) { param = quoted[1]; } } + if (!param) { var supplied = msg.match(/argument supplied:\s*([a-zA-Z_][a-zA-Z0-9_.]*)/i); if (supplied) { param = supplied[1]; } } + if (!param) { param = REMOVABLE_PARAMS.filter(function (p) { return (p in next) && msg.indexOf(p) !== -1; })[0]; } + if (param && (param in next)) { delete next[param]; return next; } + if (/temperature/i.test(msg) && ('temperature' in next)) { delete next.temperature; return next; } + return null; +} + +async function callOpenAI(apiKey, body) { + var payload = Object.assign({}, body); + var headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + apiKey }; + for (var attempt = 0; attempt < 4; attempt++) { + var res = await httpPostJson(OPENAI_URL, headers, payload, OPENAI_TIMEOUT_MS); + if (res.ok && res.data) { return res.data; } + var errorObj = res.data && res.data.error; + var apiMessage = errorObj && errorObj.message; + if (res.status === 400 && apiMessage) { + var adjusted = adjustUnsupportedParam(payload, errorObj, apiMessage); + if (adjusted) { payload = adjusted; continue; } + } + if (res.status === 401) { throw new Error('Invalid API key. Please check your OpenAI API key configuration.'); } + if (res.status === 429) { throw new Error('Rate limit exceeded. Please try again in a moment.'); } + if (res.status === 403) { throw new Error('Access forbidden. Please check your API key permissions.'); } + if (res.status >= 500) { throw new Error('OpenAI service is temporarily unavailable. Please try again later.'); } + throw new Error('OpenAI API error: ' + (apiMessage || ('HTTP ' + res.status))); + } + throw new Error('OpenAI API error: request rejected after adjusting unsupported parameters.'); +} + +async function runAgent(userMessage, model, apiKey, history, operationLog, permissions) { + var appName = (Parse.applicationId || 'this app'); + var messages = [{ role: 'system', content: SYSTEM_PROMPT + '\n\nContext: you are helping with the Parse app "' + appName + '".' }]; + if (Array.isArray(history)) { + history.forEach(function (m) { + if (m && m.role && m.content !== null && m.content !== undefined && m.content !== '') { + messages.push({ role: m.role === 'agent' ? 'assistant' : m.role, content: String(m.content) }); + } + }); + } + messages.push({ role: 'user', content: userMessage }); + + var body = { model: model, messages: messages, max_completion_tokens: 2000, tools: databaseTools, tool_choice: 'auto', stream: false }; + var data = await callOpenAI(apiKey, body); + if (!data || !Array.isArray(data.choices) || data.choices.length === 0) { throw new Error('No response received from OpenAI API'); } + var responseMessage = data.choices[0].message; + + if (responseMessage.tool_calls && responseMessage.tool_calls.length > 0) { + var toolResponses = []; + for (var i = 0; i < responseMessage.tool_calls.length; i++) { + var toolCall = responseMessage.tool_calls[i]; + if (toolCall.type !== 'function') { continue; } + try { + var fnArgs = JSON.parse(toolCall.function.arguments || '{}'); + var result = await executeDatabaseFunction(toolCall.function.name, fnArgs, operationLog, permissions); + toolResponses.push({ tool_call_id: toolCall.id, role: 'tool', content: result ? JSON.stringify(result) : JSON.stringify({ success: true }) }); + } catch (err) { + toolResponses.push({ tool_call_id: toolCall.id, role: 'tool', content: JSON.stringify({ error: err.message || 'Unknown error' }) }); + } + } + var followUp = { model: model, messages: messages.concat([responseMessage]).concat(toolResponses), max_completion_tokens: 2000, tools: databaseTools, tool_choice: 'auto', stream: false }; + var followUpData = await callOpenAI(apiKey, followUp); + if (!followUpData || !Array.isArray(followUpData.choices) || followUpData.choices.length === 0) { throw new Error('No follow-up response received from OpenAI API'); } + return followUpData.choices[0].message.content || 'Done.'; + } + return responseMessage.content || 'Done.'; +} + +Parse.Cloud.define('dashboardAgent', async function (request) { + // Only the dashboard (calling with the master key) may run the agent. + if (!request.master) { + throw new Parse.Error(Parse.Error.OPERATION_FORBIDDEN, 'The dashboardAgent function requires the master key.'); + } + + var params = request.params || {}; + var message = params.message; + var modelName = params.modelName; + var permissions = params.permissions || {}; + var history = params.history || []; + + if (!message || typeof message !== 'string' || message.trim() === '') { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Message is required'); + } + + var apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'No OpenAI API key configured (set the OPENAI_API_KEY environment variable).'); + } + + var models = []; + try { var raw = process.env.AGENT_MODELS; if (raw) { var parsed = JSON.parse(raw); if (Array.isArray(parsed)) { models = parsed; } } } catch (e) { models = []; } + if (models.length === 0) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'No models configured (set the AGENT_MODELS environment variable).'); + } + + var modelConfig = models.filter(function (m) { return m.name === modelName; })[0] || models[0]; + if (!modelConfig || !modelConfig.model) { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Model "' + modelName + '" not found in configuration'); + } + var provider = (modelConfig.provider || 'openai').toLowerCase(); + if (provider !== 'openai') { + throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Provider "' + provider + '" is not supported yet'); + } + + var operationLog = []; + var response = await runAgent(message.trim(), modelConfig.model, apiKey, history, operationLog, permissions); + return { response: response, debug: { modelUsed: modelConfig.model, operations: operationLog } }; +}); diff --git a/src/lib/ParseApp.js b/src/lib/ParseApp.js index 481e40bf87..fa30d4055e 100644 --- a/src/lib/ParseApp.js +++ b/src/lib/ParseApp.js @@ -1944,9 +1944,42 @@ export default class ParseApp { } } - // Run the AI Agent server-side (back4app API). The OpenAI key is read from the - // app's own env var on the backend and never leaves it — nothing secret is sent - // from the browser here. `payload` = { message, modelName, permissions, history }. + // Read the app's Cloud Code file tree (shape: { tree: [cloud, public] }). + async getCloudCode() { + try { + return ( + await axios.get( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/cloud`, + { withCredentials: true } + ) + ).data; + } catch (err) { + const apiError = err.response && err.response.data && err.response.data.error; + throw apiError ? new Error(apiError) : err; + } + } + + // Deploy a Cloud Code file tree (shape: [cloud, public]). Triggers a rebuild. + async saveCloudCode(tree) { + try { + return ( + await axios.post( + // eslint-disable-next-line no-undef + `${b4aSettings.BACK4APP_API_PATH}/parse-app/${this.slug}/cloud`, + { tree }, + { withCredentials: true } + ) + ).data; + } catch (err) { + const apiError = err.response && err.response.data && err.response.data.error; + throw apiError ? new Error(apiError) : err; + } + } + + // Run the AI Agent. The OpenAI key is read from the app's own env var and never + // leaves the backend; the heavy work runs inside the app's own Cloud Code + // container. `payload` = { message, modelName, permissions, history }. async sendAgentMessage(payload) { try { return ( diff --git a/webpack/base.config.js b/webpack/base.config.js index 52efd312df..211da24583 100644 --- a/webpack/base.config.js +++ b/webpack/base.config.js @@ -41,9 +41,16 @@ module.exports = { }, module: { rules: [ + { + // Import a file as a raw string with `import src from './file.js?raw'`. + // Used to inject Cloud Code source (the AI Agent function) into apps. + resourceQuery: /raw/, + type: 'asset/source', + }, { test: /\.js$/, exclude: /node_modules/, + resourceQuery: { not: [/raw/] }, use: ['babel-loader'], }, { From 4b3fd00a60f2af7126368b55f22e66b7127bb7de Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 13:32:23 -0300 Subject: [PATCH 29/68] Agent(cloud): drop deprecated Parse.Cloud.httpRequest; use https module fallback Parse.Cloud.httpRequest was removed in Parse Server 6+. HTTP now uses global fetch (Node 18+) with a fallback to Node's built-in https module, so the Cloud Function also runs on Node 14 (fetch/AbortController are guarded and unused there). Clearer network/timeout error message. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Data/Agent/cloud/dashboard-agent.cloud.js | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js index fcaef13f4e..4d02aea48d 100644 --- a/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js +++ b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js @@ -218,7 +218,36 @@ async function executeDatabaseFunction(functionName, args, operationLog, permiss } } -// HTTP helper: prefer global fetch (Node 18+), fall back to Parse.Cloud.httpRequest. +// POST JSON via Node's built-in https module (no deprecated APIs, zero deps). +function httpsPostJson(targetUrl, headers, bodyObj, timeoutMs) { + return new Promise(function (resolve) { + var https = require('https'); + var u = new URL(targetUrl); + var payload = JSON.stringify(bodyObj); + var options = { + method: 'POST', + hostname: u.hostname, + port: u.port || 443, + path: u.pathname + u.search, + headers: Object.assign({}, headers, { 'Content-Length': Buffer.byteLength(payload) }) + }; + var req = https.request(options, function (res) { + var chunks = ''; + res.on('data', function (c) { chunks += c; }); + res.on('end', function () { + var data = null; + try { data = JSON.parse(chunks); } catch (e) { data = null; } + resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode, data: data }); + }); + }); + req.on('error', function (err) { resolve({ ok: false, status: 0, data: { error: { message: err.message } } }); }); + req.setTimeout(timeoutMs, function () { req.destroy(); resolve({ ok: false, status: 0, data: { error: { message: 'Request timed out' } } }); }); + req.write(payload); + req.end(); + }); +} + +// HTTP helper: prefer global fetch (Node 18+), fall back to the https module. async function httpPostJson(url, headers, bodyObj, timeoutMs) { if (typeof fetch === 'function') { var controller = (typeof AbortController !== 'undefined') ? new AbortController() : null; @@ -234,14 +263,12 @@ async function httpPostJson(url, headers, bodyObj, timeoutMs) { var data = null; try { data = JSON.parse(text); } catch (e) { data = null; } return { ok: resp.ok, status: resp.status, data: data }; + } catch (err) { + var aborted = err && (err.name === 'AbortError' || /abort/i.test(err.message || '')); + return { ok: false, status: 0, data: { error: { message: aborted ? 'Request timed out' : (err.message || 'Network error') } } }; } finally { if (timer) { clearTimeout(timer); } } } - try { - var r = await Parse.Cloud.httpRequest({ method: 'POST', url: url, headers: headers, body: JSON.stringify(bodyObj) }); - return { ok: r.status >= 200 && r.status < 300, status: r.status, data: r.data }; - } catch (e) { - return { ok: false, status: e.status || 500, data: e.data }; - } + return httpsPostJson(url, headers, bodyObj, timeoutMs); } function adjustUnsupportedParam(body, errorObj, message) { @@ -277,6 +304,7 @@ async function callOpenAI(apiKey, body) { var adjusted = adjustUnsupportedParam(payload, errorObj, apiMessage); if (adjusted) { payload = adjusted; continue; } } + if (res.status === 0) { throw new Error('OpenAI request failed: ' + (apiMessage || 'network error') + '. Try a faster model (e.g. gpt-4o) or a shorter prompt.'); } if (res.status === 401) { throw new Error('Invalid API key. Please check your OpenAI API key configuration.'); } if (res.status === 429) { throw new Error('Rate limit exceeded. Please try again in a moment.'); } if (res.status === 403) { throw new Error('Access forbidden. Please check your API key permissions.'); } From e61828b5d9631b5987afdf3fbe22ed6605930433 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 13:37:50 -0300 Subject: [PATCH 30/68] Agent(cloud): inject real app context; stop master-key/hallucination confusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent was inventing app IDs / server URLs and telling users it lacked the master key. Now the proxy passes the real app context (appId, appName, dashboardAPI serverURL) into the function, which injects it into the system prompt as authoritative values. Added instructions: DB access is already granted via tools (master key handled server-side) — never ask for it, never fabricate IDs/URLs/schema, and call getSchema/queryClass to answer instead of guessing. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Data/Agent/cloud/dashboard-agent.cloud.js | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js index 4d02aea48d..b8efbb3e93 100644 --- a/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js +++ b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js @@ -78,6 +78,12 @@ var SYSTEM_PROMPT = [ 'When creating/updating objects you MUST provide the objectData parameter with the actual field values.', 'If a database function returns an error, include the full error message in your response.', '', + 'DATABASE ACCESS & MASTER KEY:', + '- You already have FULL database access through the tools; the master key is applied for you server-side.', + '- NEVER tell the user you lack the master key, and NEVER ask them for it. If a request needs data, just call the appropriate tool.', + '- To answer questions about the app\'s structure or data, CALL the tools (getSchema, queryClass, countObjects) — do not guess.', + '- Never fabricate app IDs, server URLs, class names, or field names. Only state values you got from the app context below or from a tool result.', + '', 'Format responses using Markdown (bold, code, lists, tables, headers) for readability.' ].join('\n'); @@ -314,9 +320,17 @@ async function callOpenAI(apiKey, body) { throw new Error('OpenAI API error: request rejected after adjusting unsupported parameters.'); } -async function runAgent(userMessage, model, apiKey, history, operationLog, permissions) { - var appName = (Parse.applicationId || 'this app'); - var messages = [{ role: 'system', content: SYSTEM_PROMPT + '\n\nContext: you are helping with the Parse app "' + appName + '".' }]; +async function runAgent(userMessage, model, apiKey, history, operationLog, permissions, appContext) { + var ctx = appContext || {}; + var appName = ctx.appName || Parse.applicationId || 'this app'; + var contextLines = [ + '', + 'APP CONTEXT (authoritative — use these exact values, do not invent others):', + '- App name: ' + appName, + '- App ID: ' + (ctx.appId || Parse.applicationId || 'unknown'), + '- Parse Server URL: ' + (ctx.serverURL || 'unknown') + ].join('\n'); + var messages = [{ role: 'system', content: SYSTEM_PROMPT + '\n' + contextLines }]; if (Array.isArray(history)) { history.forEach(function (m) { if (m && m.role && m.content !== null && m.content !== undefined && m.content !== '') { @@ -363,6 +377,7 @@ Parse.Cloud.define('dashboardAgent', async function (request) { var modelName = params.modelName; var permissions = params.permissions || {}; var history = params.history || []; + var appContext = params.appContext || {}; if (!message || typeof message !== 'string' || message.trim() === '') { throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'Message is required'); @@ -389,6 +404,6 @@ Parse.Cloud.define('dashboardAgent', async function (request) { } var operationLog = []; - var response = await runAgent(message.trim(), modelConfig.model, apiKey, history, operationLog, permissions); + var response = await runAgent(message.trim(), modelConfig.model, apiKey, history, operationLog, permissions, appContext); return { response: response, debug: { modelUsed: modelConfig.model, operations: operationLog } }; }); From 0c428679e8c76e6e644766b10f4c2bbb9af985b0 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 14:42:58 -0300 Subject: [PATCH 31/68] Agent: encode Cloud Code as base64 data URI (matches deploy format) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deploy endpoint (POST /parse-app/:id/cloud → CLI /deploy) and the dashboard's own Cloud Code editor store data.code as a base64 data URI (data:...;base64,); writeJsTreeData treats a non-data-URI string AS base64, so writing raw text would corrupt the file. Now encode our writes and decode when reading (UTF-8 safe, so a customer's accented main.js round-trips intact). Confirmed the endpoint/body ({tree:[cloud,public]}) matches the dashboard's existing deploy exactly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Data/Agent/agentCloudProvisioning.js | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/dashboard/Data/Agent/agentCloudProvisioning.js b/src/dashboard/Data/Agent/agentCloudProvisioning.js index 7ac253c55d..f927409006 100644 --- a/src/dashboard/Data/Agent/agentCloudProvisioning.js +++ b/src/dashboard/Data/Agent/agentCloudProvisioning.js @@ -26,12 +26,48 @@ export class CloudCollisionError extends Error { } } +// Cloud Code file contents are stored as base64 data URIs (data:...;base64,), +// matching how the dashboard's Cloud Code editor and the deploy endpoint encode +// them. These helpers convert to/from that form. UTF-8 safe: a customer's +// main.js may contain accented characters that plain btoa/atob would corrupt. +const DATA_URI_PREFIX = 'data:plain/text;base64,'; + +function toBase64Utf8(str) { + const bytes = new TextEncoder().encode(str); + let bin = ''; + for (let i = 0; i < bytes.length; i++) { bin += String.fromCharCode(bytes[i]); } + return window.btoa(bin); +} + +function fromBase64Utf8(b64) { + try { + const bin = window.atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) { bytes[i] = bin.charCodeAt(i); } + return new TextDecoder().decode(bytes); + } catch (e) { + return ''; + } +} + +function encodeCode(text) { + return DATA_URI_PREFIX + toBase64Utf8(text); +} + +function decodeCode(code) { + if (typeof code !== 'string' || !code) { return ''; } + const idx = code.indexOf(';base64,'); + const b64 = idx !== -1 ? code.slice(idx + ';base64,'.length) : code; + return fromBase64Utf8(b64); +} + function findCloudFolder(tree) { return (tree || []).find(node => node && node.text === 'cloud' && (node.type === 'folder' || Array.isArray(node.children))); } +// Returns the DECODED source text of a file node. function fileCode(node) { - return (node && node.data && typeof node.data.code === 'string') ? node.data.code : ''; + return decodeCode(node && node.data ? node.data.code : ''); } // True if the app already has our agent file installed (marker present). @@ -48,14 +84,14 @@ export function isAgentInstalled(tree) { function ensureRequire(cloud) { const main = cloud.children.find(n => n && n.text === 'main.js'); if (!main) { - cloud.children.push({ text: 'main.js', data: { code: REQUIRE_SNIPPET + '\n' } }); + cloud.children.push({ text: 'main.js', data: { code: encodeCode(REQUIRE_SNIPPET + '\n') } }); return; } const code = fileCode(main); if (code.indexOf(REQUIRE_MATCH) === -1) { const sep = code.length && !code.endsWith('\n') ? '\n' : ''; main.data = main.data || {}; - main.data.code = code + sep + REQUIRE_SNIPPET + '\n'; + main.data.code = encodeCode(code + sep + REQUIRE_SNIPPET + '\n'); } } @@ -90,16 +126,16 @@ export function injectAgent(tree) { throw new CloudCollisionError('A file "' + AGENT_DIR + '/' + AGENT_FILE + '" already exists in your Cloud Code and is not managed by the dashboard. Rename or remove it before configuring the AI Agent.'); } if (file) { - file.data = { code: AGENT_SOURCE }; + file.data = { code: encodeCode(AGENT_SOURCE) }; } else { - existing.children.push({ text: AGENT_FILE, data: { code: AGENT_SOURCE } }); + existing.children.push({ text: AGENT_FILE, data: { code: encodeCode(AGENT_SOURCE) } }); } } else { cloud.children.push({ text: AGENT_DIR, type: 'folder', state: { opened: false }, - children: [{ text: AGENT_FILE, data: { code: AGENT_SOURCE } }] + children: [{ text: AGENT_FILE, data: { code: encodeCode(AGENT_SOURCE) } }] }); } @@ -135,7 +171,7 @@ export function removeAgent(tree) { if (main) { const lines = fileCode(main).split('\n').filter(line => line.indexOf(REQUIRE_MATCH) === -1); main.data = main.data || {}; - main.data.code = lines.join('\n'); + main.data.code = encodeCode(lines.join('\n')); } next.forEach(node => { if (node) { delete node.mainJsNotExists; delete node.indexHtmlNotExists; } }); From 363b086f26b6a2e49cebc459f0558bb9a1cddabb Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 14:54:01 -0300 Subject: [PATCH 32/68] Agent: store AGENT_MODELS env var as base64 (JSON was mangled in container) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The container's env-var injection mangles JSON (quotes/braces), so process.env.AGENT_MODELS reached the Cloud Function unparseable → "No models configured" even though it was set (OPENAI_API_KEY, a plain string, worked fine — that's the tell). Store the model list base64-encoded (charset [A-Za-z0-9+/=] survives injection intact). Reader accepts both base64 and legacy plain JSON, so existing configs keep working until re-saved. Applied on both the dashboard (writer/reader) and the Cloud Function (container reader). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dashboard/Data/Agent/Agent.react.js | 41 +++++++++++++------ .../Data/Agent/cloud/dashboard-agent.cloud.js | 17 +++++++- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/dashboard/Data/Agent/Agent.react.js b/src/dashboard/Data/Agent/Agent.react.js index cac0315cfb..64956a56e3 100644 --- a/src/dashboard/Data/Agent/Agent.react.js +++ b/src/dashboard/Data/Agent/Agent.react.js @@ -26,8 +26,35 @@ import { CurrentApp } from 'context/currentApp'; 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; @@ -117,7 +144,7 @@ class Agent extends DashboardView { if (app && app.getEnvVars && app.updateEnvVars) { const existing = await app.getEnvVars(); const envVars = (existing && existing.envVars) || {}; - const next = { ...envVars, [AGENT_MODELS_ENV_KEY]: JSON.stringify(cleanModels) }; + const next = { ...envVars, [AGENT_MODELS_ENV_KEY]: encodeAgentModels(cleanModels) }; if (apiKey) { next[AGENT_ENV_KEY] = apiKey; } @@ -187,17 +214,7 @@ class Agent extends DashboardView { const existing = await this.context.getEnvVars(); const envVars = (existing && existing.envVars) || {}; apiKey = envVars[AGENT_ENV_KEY]; - const rawModels = envVars[AGENT_MODELS_ENV_KEY]; - if (rawModels) { - try { - const parsed = JSON.parse(rawModels); - if (Array.isArray(parsed)) { - models = parsed; - } - } catch (error) { - console.warn('Failed to parse AGENT_MODELS env var:', error); - } - } + models = decodeAgentModels(envVars[AGENT_MODELS_ENV_KEY]); } } catch (error) { console.warn('Failed to read agent config from env vars:', error); diff --git a/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js index b8efbb3e93..7fa83ab9b4 100644 --- a/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js +++ b/src/dashboard/Data/Agent/cloud/dashboard-agent.cloud.js @@ -366,6 +366,18 @@ async function runAgent(userMessage, model, apiKey, history, operationLog, permi return responseMessage.content || 'Done.'; } +// Parse the AGENT_MODELS env var. Prefer base64(JSON); fall back to plain JSON. +function parseAgentModels(raw) { + if (!raw) { return []; } + try { var p = JSON.parse(raw); if (Array.isArray(p)) { return p; } } catch (e) { /* not plain JSON */ } + try { + var json = Buffer.from(raw, 'base64').toString('utf8'); + var p2 = JSON.parse(json); + if (Array.isArray(p2)) { return p2; } + } catch (e) { /* not base64 JSON */ } + return []; +} + Parse.Cloud.define('dashboardAgent', async function (request) { // Only the dashboard (calling with the master key) may run the agent. if (!request.master) { @@ -388,8 +400,9 @@ Parse.Cloud.define('dashboardAgent', async function (request) { throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'No OpenAI API key configured (set the OPENAI_API_KEY environment variable).'); } - var models = []; - try { var raw = process.env.AGENT_MODELS; if (raw) { var parsed = JSON.parse(raw); if (Array.isArray(parsed)) { models = parsed; } } } catch (e) { models = []; } + // AGENT_MODELS is stored base64-encoded (JSON with quotes/braces gets mangled + // by container env-var injection). Accept both base64 and legacy plain JSON. + var models = parseAgentModels(process.env.AGENT_MODELS); if (models.length === 0) { throw new Parse.Error(Parse.Error.VALIDATION_ERROR, 'No models configured (set the AGENT_MODELS environment variable).'); } From a8beef18545b634e77be92748ee082c6ec0c14a1 Mon Sep 17 00:00:00 2001 From: charles-ramos Date: Mon, 24 Aug 2026 15:06:10 -0300 Subject: [PATCH 33/68] Agent: remove model label from toolbar; fix chat layout + Shift+Enter - Remove the confusing active-model name label from the toolbar. - Rewrite the chat layout as a proper flex column: .chatContainer is a fixed box below the toolbar (top:127px), .chatWindow is the single scroll area (flex:1 + min-height:0), and .chatForm is a static bottom child. Removes the conflicting 100vh-60px heights + padding-top:127 hack that broke scrolling and let the warning overlap the toolbar. - Chat input is now a