From cdc9db0ec799d62ea8ff343e6fc4b6c66f882f52 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 5 Mar 2026 16:38:14 -0500 Subject: [PATCH 1/2] Improve SQL action ui and config --- application/single_app/config.py | 2 +- .../single_app/route_backend_plugins.py | 126 ++++++++++ .../route_backend_user_agreement.py | 2 +- .../single_app/static/images/custom_logo.png | Bin 0 -> 11877 bytes .../static/images/custom_logo_dark.png | Bin 0 -> 13468 bytes .../static/js/plugin_modal_stepper.js | 219 +++++++++++++----- .../static/js/workspace/group_agents.js | 49 +++- .../json/schemas/sql_query.definition.json | 3 + ...ery_plugin.additional_settings.schema.json | 8 +- .../json/schemas/sql_schema.definition.json | 3 + ...ema_plugin.additional_settings.schema.json | 8 +- .../single_app/templates/_plugin_modal.html | 48 +++- docs/explanation/release_notes.md | 28 +++ 13 files changed, 422 insertions(+), 74 deletions(-) create mode 100644 application/single_app/static/images/custom_logo.png create mode 100644 application/single_app/static/images/custom_logo_dark.png diff --git a/application/single_app/config.py b/application/single_app/config.py index da63c230..a934fea7 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.239.004" +VERSION = "0.239.007" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 77aab866..084a5ee9 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -460,6 +460,13 @@ def create_group_action_route(): for key in ('group_id', 'last_updated', 'user_id', 'is_global', 'is_group', 'scope'): payload.pop(key, None) + # Handle endpoint based on plugin type (same logic as personal plugins) + plugin_type = payload.get('type', '') + if plugin_type in ['sql_schema', 'sql_query']: + payload.setdefault('endpoint', f'sql://{plugin_type}') + elif plugin_type == 'msgraph': + payload.setdefault('endpoint', 'https://graph.microsoft.com') + # Merge with schema to ensure all required fields are present (same as global actions) schema_dir = os.path.join(current_app.root_path, 'static', 'json', 'schemas') merged = get_merged_plugin_settings(payload.get('type'), payload, schema_dir) @@ -516,6 +523,13 @@ def update_group_action_route(action_id): merged['is_group'] = True merged['id'] = existing.get('id', action_id) + # Handle endpoint based on plugin type (same logic as personal plugins) + plugin_type = merged.get('type', '') + if plugin_type in ['sql_schema', 'sql_query']: + merged.setdefault('endpoint', f'sql://{plugin_type}') + elif plugin_type == 'msgraph': + merged.setdefault('endpoint', 'https://graph.microsoft.com') + try: validate_group_action_payload(merged, partial=False) except ValueError as exc: @@ -928,4 +942,116 @@ def _merge_group_and_global_actions(group_actions, global_actions): return normalized_actions +@bpap.route('/api/plugins/test-sql-connection', methods=['POST']) +@swagger_route(security=get_auth_security()) +@login_required +@user_required +def test_sql_connection(): + """Test a SQL database connection using provided configuration.""" + data = request.get_json(silent=True) or {} + database_type = (data.get('database_type') or 'sqlserver').lower() + connection_method = data.get('connection_method', 'parameters') + connection_string = data.get('connection_string', '') + server = data.get('server', '') + database = data.get('database', '') + port = data.get('port', '') + driver = data.get('driver', '') + username = data.get('username', '') + password = data.get('password', '') + auth_type = data.get('auth_type', 'username_password') + timeout = min(int(data.get('timeout', 10)), 15) # Cap at 15 seconds for test + + # Map azure_sql to sqlserver + if database_type in ('azure_sql', 'azuresql'): + database_type = 'sqlserver' + + try: + if database_type == 'sqlserver': + import pyodbc + if connection_method == 'connection_string' and connection_string: + conn = pyodbc.connect(connection_string, timeout=timeout) + else: + if not server or not database: + return jsonify({'success': False, 'error': 'Server and database are required for individual parameters connection.'}), 400 + drv = driver or 'ODBC Driver 17 for SQL Server' + conn_str = f"DRIVER={{{drv}}};SERVER={server};DATABASE={database}" + if port: + conn_str += f",{port}" + if auth_type == 'username_password' and username and password: + conn_str += f";UID={username};PWD={password}" + elif auth_type == 'managed_identity': + conn_str += ";Authentication=ActiveDirectoryMsi" + elif auth_type == 'integrated': + conn_str += ";Trusted_Connection=yes" + conn = pyodbc.connect(conn_str, timeout=timeout) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to {data.get("database", "database")} on {data.get("server", "server")}.'}) + + elif database_type == 'postgresql': + import psycopg2 + if connection_method == 'connection_string' and connection_string: + conn = psycopg2.connect(connection_string, connect_timeout=timeout) + else: + if not server or not database: + return jsonify({'success': False, 'error': 'Server and database are required.'}), 400 + conn_params = {'host': server, 'database': database, 'connect_timeout': timeout} + if port: + conn_params['port'] = int(port) + if username: + conn_params['user'] = username + if password: + conn_params['password'] = password + conn = psycopg2.connect(**conn_params) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to PostgreSQL database {data.get("database", "")}.'}) + + elif database_type == 'mysql': + import pymysql + if connection_method == 'connection_string' and connection_string: + # pymysql doesn't natively parse connection strings, so use params + return jsonify({'success': False, 'error': 'MySQL test connection requires individual parameters, not a connection string.'}), 400 + if not server or not database: + return jsonify({'success': False, 'error': 'Server and database are required.'}), 400 + conn_params = {'host': server, 'database': database, 'connect_timeout': timeout} + if port: + conn_params['port'] = int(port) + if username: + conn_params['user'] = username + if password: + conn_params['password'] = password + conn = pymysql.connect(**conn_params) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to MySQL database {data.get("database", "")}.'}) + + elif database_type == 'sqlite': + import sqlite3 + db_path = connection_string or database + if not db_path: + return jsonify({'success': False, 'error': 'Database path is required for SQLite.'}), 400 + conn = sqlite3.connect(db_path, timeout=timeout) + cursor = conn.cursor() + cursor.execute("SELECT 1") + cursor.close() + conn.close() + return jsonify({'success': True, 'message': f'Successfully connected to SQLite database.'}) + + else: + return jsonify({'success': False, 'error': f'Unsupported database type: {database_type}'}), 400 + except ImportError as e: + return jsonify({'success': False, 'error': f'Database driver not installed: {str(e)}'}), 400 + except Exception as e: + error_msg = str(e) + # Sanitize error message to avoid leaking sensitive details + if 'password' in error_msg.lower() or 'pwd' in error_msg.lower(): + error_msg = 'Authentication failed. Please check your credentials.' + return jsonify({'success': False, 'error': f'Connection failed: {error_msg}'}), 400 diff --git a/application/single_app/route_backend_user_agreement.py b/application/single_app/route_backend_user_agreement.py index f46559ff..b76213b3 100644 --- a/application/single_app/route_backend_user_agreement.py +++ b/application/single_app/route_backend_user_agreement.py @@ -130,7 +130,7 @@ def api_accept_user_agreement(): return jsonify({"error": "workspace_id and workspace_type are required"}), 400 # Validate workspace type - valid_types = ["personal", "group", "public"] + valid_types = ["personal", "group", "public", "chat"] if workspace_type not in valid_types: return jsonify({"error": f"Invalid workspace_type. Must be one of: {', '.join(valid_types)}"}), 400 diff --git a/application/single_app/static/images/custom_logo.png b/application/single_app/static/images/custom_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..ecf6e6521a737af56bcc82321caff1acefb63494 GIT binary patch literal 11877 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 F{sTGwC=37q literal 0 HcmV?d00001 diff --git a/application/single_app/static/images/custom_logo_dark.png b/application/single_app/static/images/custom_logo_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..4f28194576a32f4463ff13ac96521f979e739bb0 GIT binary patch literal 13468 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 aj13{1xX1-4^7hveoPvysbhV^u=>Gr|kZNTB literal 0 HcmV?d00001 diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 89076076..6729124f 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -2,6 +2,9 @@ // Multi-step modal functionality for action/plugin creation import { showToast } from "./chat/chat-toast.js"; +// Action types hidden from the creation UI (backend plugins remain intact) +const HIDDEN_ACTION_TYPES = ['sql_schema', 'ui_test', 'queue_storage', 'blob_storage', 'embedding_model']; + export class PluginModalStepper { @@ -129,6 +132,12 @@ export class PluginModalStepper { document.getElementById('sql-auth-type').addEventListener('change', () => this.handleSqlAuthTypeChange()); + // Test SQL connection button + const testConnBtn = document.getElementById('sql-test-connection-btn'); + if (testConnBtn) { + testConnBtn.addEventListener('click', () => this.testSqlConnection()); + } + // Set up display name to generated name conversion this.setupNameGeneration(); @@ -193,6 +202,8 @@ export class PluginModalStepper { if (!res.ok) throw new Error('Failed to load action types'); this.availableTypes = await res.json(); + // Hide deprecated/internal action types from the creation UI + this.availableTypes = this.availableTypes.filter(t => !HIDDEN_ACTION_TYPES.includes(t.type)); // Sort action types alphabetically by display name this.availableTypes.sort((a, b) => { const nameA = (a.display || a.displayName || a.type || a.name || '').toLowerCase(); @@ -538,43 +549,52 @@ export class PluginModalStepper { } if (stepNumber === 4) { - // Load additional settings schema for selected type - let options = {forceReload: true}; - this.getAdditionalSettingsSchema(this.selectedType, options); + const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema'; const additionalFieldsDiv = document.getElementById('plugin-additional-fields-div'); - if (additionalFieldsDiv) { - // Only clear and rebuild if type changes - if (this.selectedType !== this.lastAdditionalFieldsType) { - additionalFieldsDiv.innerHTML = ''; - additionalFieldsDiv.classList.remove('d-none'); - if (this.selectedType) { - this.getAdditionalSettingsSchema(this.selectedType) - .then(schema => { - if (schema) { - this.buildAdditionalFieldsUI(schema, additionalFieldsDiv); - try { - if (this.isEditMode && this.originalPlugin && this.originalPlugin.additionalFields) { - this.populateDynamicAdditionalFields(this.originalPlugin.additionalFields); + + // For SQL types, hide additional fields entirely since Step 3 covers all SQL config + if (isSqlType && additionalFieldsDiv) { + additionalFieldsDiv.innerHTML = ''; + additionalFieldsDiv.classList.add('d-none'); + this.lastAdditionalFieldsType = this.selectedType; + } else { + // Load additional settings schema for selected type + let options = {forceReload: true}; + this.getAdditionalSettingsSchema(this.selectedType, options); + if (additionalFieldsDiv) { + // Only clear and rebuild if type changes + if (this.selectedType !== this.lastAdditionalFieldsType) { + additionalFieldsDiv.innerHTML = ''; + additionalFieldsDiv.classList.remove('d-none'); + if (this.selectedType) { + this.getAdditionalSettingsSchema(this.selectedType) + .then(schema => { + if (schema) { + this.buildAdditionalFieldsUI(schema, additionalFieldsDiv); + try { + if (this.isEditMode && this.originalPlugin && this.originalPlugin.additionalFields) { + this.populateDynamicAdditionalFields(this.originalPlugin.additionalFields); + } + } catch (error) { + console.error('Error populating dynamic additional fields:', error); } - } catch (error) { - console.error('Error populating dynamic additional fields:', error); + } else { + console.log('No additional settings schema found'); + additionalFieldsDiv.classList.add('d-none'); } - } else { - console.log('No additional settings schema found'); + }) + .catch(error => { + console.error(`Error fetching additional settings schema for type: ${this.selectedType} -- ${error}`); additionalFieldsDiv.classList.add('d-none'); - } - }) - .catch(error => { - console.error(`Error fetching additional settings schema for type: ${this.selectedType} -- ${error}`); - additionalFieldsDiv.classList.add('d-none'); - }); - } else { - console.warn('No plugin type selected'); - additionalFieldsDiv.classList.add('d-none'); + }); + } else { + console.warn('No plugin type selected'); + additionalFieldsDiv.classList.add('d-none'); + } + this.lastAdditionalFieldsType = this.selectedType; } - this.lastAdditionalFieldsType = this.selectedType; + // Otherwise, preserve user data and do not redraw } - // Otherwise, preserve user data and do not redraw } if (!this.isEditMode) { @@ -1230,6 +1250,80 @@ export class PluginModalStepper { this.updateSqlAuthInfo(); } + async testSqlConnection() { + const btn = document.getElementById('sql-test-connection-btn'); + const resultDiv = document.getElementById('sql-test-connection-result'); + const alertDiv = document.getElementById('sql-test-connection-alert'); + if (!btn || !resultDiv || !alertDiv) return; + + // Collect current SQL config from Step 3 + const databaseType = document.querySelector('input[name="sql-database-type"]:checked')?.value; + const connectionMethod = document.querySelector('input[name="sql-connection-method"]:checked')?.value || 'parameters'; + const authType = document.getElementById('sql-auth-type')?.value || 'username_password'; + + if (!databaseType) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-warning mb-0 py-2 px-3 small'; + alertDiv.textContent = 'Please select a database type first.'; + return; + } + + const payload = { + database_type: databaseType, + connection_method: connectionMethod, + auth_type: authType + }; + + if (connectionMethod === 'connection_string') { + payload.connection_string = document.getElementById('sql-connection-string')?.value?.trim() || ''; + } else { + payload.server = document.getElementById('sql-server')?.value?.trim() || ''; + payload.database = document.getElementById('sql-database')?.value?.trim() || ''; + payload.port = document.getElementById('sql-port')?.value?.trim() || ''; + if (databaseType === 'sqlserver' || databaseType === 'azure_sql') { + payload.driver = document.getElementById('sql-driver')?.value || ''; + } + } + + if (authType === 'username_password') { + payload.username = document.getElementById('sql-username')?.value?.trim() || ''; + payload.password = document.getElementById('sql-password')?.value?.trim() || ''; + } + + payload.timeout = parseInt(document.getElementById('sql-timeout')?.value) || 10; + + // Show loading state + const originalText = btn.innerHTML; + btn.innerHTML = 'Testing...'; + btn.disabled = true; + resultDiv.classList.add('d-none'); + + try { + const response = await fetch('/api/plugins/test-sql-connection', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json(); + + resultDiv.classList.remove('d-none'); + if (data.success) { + alertDiv.className = 'alert alert-success mb-0 py-2 px-3 small'; + alertDiv.innerHTML = '' + (data.message || 'Connection successful!'); + } else { + alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small'; + alertDiv.innerHTML = '' + (data.error || 'Connection failed.'); + } + } catch (error) { + resultDiv.classList.remove('d-none'); + alertDiv.className = 'alert alert-danger mb-0 py-2 px-3 small'; + alertDiv.innerHTML = 'Test failed: ' + (error.message || 'Network error'); + } finally { + btn.innerHTML = originalText; + btn.disabled = false; + } + } + updateSqlConnectionExamples() { const selectedType = document.querySelector('input[name="sql-database-type"]:checked')?.value; const examplesDiv = document.getElementById('sql-connection-examples'); @@ -1720,12 +1814,17 @@ export class PluginModalStepper { // Collect additional fields from the dynamic UI and MERGE with existing additionalFields // This preserves OpenAPI spec content and other auto-populated fields - try { - const dynamicFields = this.collectAdditionalFields(); - // Merge dynamicFields into additionalFields (preserving existing values) - additionalFields = { ...additionalFields, ...dynamicFields }; - } catch (e) { - throw new Error('Invalid additional fields input'); + // For SQL types, Step 3 already provides all necessary config — skip dynamic field merge + // to prevent empty Step 4 fields from overwriting populated Step 3 values + const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema'; + if (!isSqlType) { + try { + const dynamicFields = this.collectAdditionalFields(); + // Merge dynamicFields into additionalFields (preserving existing values) + additionalFields = { ...additionalFields, ...dynamicFields }; + } catch (e) { + throw new Error('Invalid additional fields input'); + } } let metadata = {}; @@ -2106,6 +2205,7 @@ export class PluginModalStepper { populateAdvancedSummary() { const advancedSection = document.getElementById('summary-advanced-section'); + const isSqlType = this.selectedType === 'sql_query' || this.selectedType === 'sql_schema'; // Check if there's any metadata or additional fields const metadata = document.getElementById('plugin-metadata').value.trim(); @@ -2123,9 +2223,33 @@ export class PluginModalStepper { hasMetadata = metadata.length > 0 && metadata !== '{}'; } - // DRY: Use private helper to collect additional fields - let additionalFieldsObj = this.collectAdditionalFields(); - hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0; + // For SQL types, additional fields are already shown in the SQL Database Configuration + // summary section, so skip showing them again in Advanced to avoid redundancy + if (!isSqlType) { + // DRY: Use private helper to collect additional fields + let additionalFieldsObj = this.collectAdditionalFields(); + hasAdditionalFields = Object.keys(additionalFieldsObj).length > 0; + + // Show/hide additional fields preview + const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview'); + if (hasAdditionalFields) { + let previewContent = ''; + if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) { + previewContent = JSON.stringify(additionalFieldsObj, null, 2); + } else { + previewContent = ''; + } + document.getElementById('summary-additional-fields-content').textContent = previewContent; + additionalFieldsPreview.style.display = ''; + } else { + additionalFieldsPreview.style.display = 'none'; + } + } else { + // Hide additional fields for SQL types + const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview'); + if (additionalFieldsPreview) additionalFieldsPreview.style.display = 'none'; + hasAdditionalFields = false; + } // Update has metadata/additional fields indicators document.getElementById('summary-has-metadata').textContent = hasMetadata ? 'Yes' : 'No'; @@ -2140,21 +2264,6 @@ export class PluginModalStepper { metadataPreview.style.display = 'none'; } - // Show/hide additional fields preview - const additionalFieldsPreview = document.getElementById('summary-additional-fields-preview'); - if (hasAdditionalFields) { - let previewContent = ''; - if (typeof additionalFieldsObj === 'object' && additionalFieldsObj !== null) { - previewContent = JSON.stringify(additionalFieldsObj, null, 2); - } else { - previewContent = ''; - } - document.getElementById('summary-additional-fields-content').textContent = previewContent; - additionalFieldsPreview.style.display = ''; - } else { - additionalFieldsPreview.style.display = 'none'; - } - // Show advanced section if there's any advanced content if (hasMetadata || hasAdditionalFields) { advancedSection.style.display = ''; diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js index f97dbd07..56157d65 100644 --- a/application/single_app/static/js/workspace/group_agents.js +++ b/application/single_app/static/js/workspace/group_agents.js @@ -117,9 +117,12 @@ function renderAgentsTable(list) { const displayName = truncateName(agent.display_name || agent.displayName || agent.name || ""); const description = escapeHtml(agent.description || "No description available."); - let actionsHtml = ""; + let actionsHtml = ` + `; if (canManage) { - actionsHtml = ` + actionsHtml += ` @@ -343,7 +346,49 @@ async function fetchGroupAgents() { } } +async function chatWithGroupAgent(agentName) { + try { + const agent = agents.find(a => a.name === agentName); + if (!agent) { + throw new Error("Agent not found"); + } + + const payloadData = { + selected_agent: { + name: agentName, + display_name: agent.display_name || agent.displayName || agentName, + is_global: !!agent.is_global, + is_group: true, + group_id: currentContext.activeGroupId, + group_name: currentContext.activeGroupName + } + }; + + const resp = await fetch("/api/user/settings/selected_agent", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payloadData) + }); + + if (!resp.ok) { + throw new Error("Failed to select agent"); + } + + window.location.href = "/chats"; + } catch (err) { + console.error("Error selecting group agent for chat:", err); + showToast("Error selecting agent for chat. Please try again.", "danger"); + } +} + function handleTableClick(event) { + const chatBtn = event.target.closest(".chat-group-agent-btn"); + if (chatBtn) { + const agentName = chatBtn.dataset.agentName; + chatWithGroupAgent(agentName); + return; + } + const editBtn = event.target.closest(".edit-group-agent-btn"); if (editBtn) { const agentId = editBtn.dataset.agentId; diff --git a/application/single_app/static/json/schemas/sql_query.definition.json b/application/single_app/static/json/schemas/sql_query.definition.json index d38a41a8..6903c22a 100644 --- a/application/single_app/static/json/schemas/sql_query.definition.json +++ b/application/single_app/static/json/schemas/sql_query.definition.json @@ -1,6 +1,9 @@ { "$schema": "./plugin.definition.schema.json", "allowedAuthTypes": [ + "user", + "identity", + "servicePrincipal", "connection_string" ] } diff --git a/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json index 9e4f6d34..f7f46ebd 100644 --- a/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json +++ b/application/single_app/static/json/schemas/sql_query_plugin.additional_settings.schema.json @@ -3,13 +3,13 @@ "title": "SQL Query Plugin Additional Settings", "type": "object", "properties": { - "connection_string__Secret": { + "connection_string": { "type": "string", "description": "Database connection string. Required if server/database not provided." }, "database_type": { "type": "string", - "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"], + "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql"], "description": "Type of database engine." }, "server": { @@ -24,7 +24,7 @@ "type": "string", "description": "Username for authentication." }, - "password__Secret": { + "password": { "type": "string", "description": "Password for authentication." }, @@ -50,6 +50,6 @@ "description": "Query timeout in seconds." } }, - "required": ["database_type", "database"], + "required": ["database_type"], "additionalProperties": false } diff --git a/application/single_app/static/json/schemas/sql_schema.definition.json b/application/single_app/static/json/schemas/sql_schema.definition.json index d38a41a8..6903c22a 100644 --- a/application/single_app/static/json/schemas/sql_schema.definition.json +++ b/application/single_app/static/json/schemas/sql_schema.definition.json @@ -1,6 +1,9 @@ { "$schema": "./plugin.definition.schema.json", "allowedAuthTypes": [ + "user", + "identity", + "servicePrincipal", "connection_string" ] } diff --git a/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json index e97c7b4b..29fb6b3f 100644 --- a/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json +++ b/application/single_app/static/json/schemas/sql_schema_plugin.additional_settings.schema.json @@ -3,13 +3,13 @@ "title": "SQL Schema Plugin Additional Settings", "type": "object", "properties": { - "connection_string__Secret": { + "connection_string": { "type": "string", "description": "Database connection string. Required if server/database not provided." }, "database_type": { "type": "string", - "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql", "azuresql"], + "enum": ["sqlserver", "postgresql", "mysql", "sqlite", "azure_sql"], "description": "Type of database engine." }, "server": { @@ -24,7 +24,7 @@ "type": "string", "description": "Username for authentication." }, - "password__Secret": { + "password": { "type": "string", "description": "Password for authentication." }, @@ -33,6 +33,6 @@ "description": "ODBC or DB driver name." } }, - "required": ["database_type", "database"], + "required": ["database_type"], "additionalProperties": false } diff --git a/application/single_app/templates/_plugin_modal.html b/application/single_app/templates/_plugin_modal.html index 3af18019..8acef22d 100644 --- a/application/single_app/templates/_plugin_modal.html +++ b/application/single_app/templates/_plugin_modal.html @@ -425,21 +425,46 @@
API Information
+ + +
+
+ +
+ + +
+
+
+ +
+
Advanced
+

Advanced settings are typically not required. Expand below if you need to customize metadata or additional fields.

- - -
Optional metadata for this action.
+
-
- - -
Additional configuration fields specific to this action type.
+
+
+ + +
Optional metadata for this action.
+
+
+ + +
Additional configuration fields specific to this action type.
+
@@ -777,6 +802,15 @@
background-color: #f8f9fa; } +/* Advanced toggle chevron animation */ +#plugin-advanced-toggle-icon { + transition: transform 0.3s ease; +} +#plugin-advanced-collapse.show ~ .mb-3 #plugin-advanced-toggle-icon, +[aria-expanded="true"] #plugin-advanced-toggle-icon { + transform: rotate(180deg); +} + .sql-connection-config, .sql-auth-config { background-color: white; diff --git a/docs/explanation/release_notes.md b/docs/explanation/release_notes.md index 8077ff3f..0f0fe0b6 100644 --- a/docs/explanation/release_notes.md +++ b/docs/explanation/release_notes.md @@ -2,6 +2,34 @@ # Feature Release +### **(v0.239.005)** + +#### Bug Fixes + +* **Group SQL Action/Plugin Save Failure** + * Fixed group SQL actions (sql_query and sql_schema types) failing to save correctly due to missing endpoint placeholder. Group routes now apply the same `sql://sql_query` / `sql://sql_schema` endpoint logic as personal action routes. + * Fixed Step 4 (Advanced) dynamic fields overwriting Step 3 (Configuration) SQL values with empty strings during form data collection. SQL types now skip the dynamic field merge entirely since Step 3 already provides all necessary configuration. + * Fixed auth type definition schemas (`sql_query.definition.json`, `sql_schema.definition.json`) only allowing `connection_string` auth type, blocking `user`, `identity`, and `servicePrincipal` types that the UI and runtime support. + * Fixed `__Secret` key suffix mismatch in additional settings schemas where `connection_string__Secret` and `password__Secret` didn't match the runtime's expected `connection_string` and `password` field names. Also removed duplicate `azuresql` enum value. + * (Ref: `route_backend_plugins.py`, `plugin_modal_stepper.js`, `sql_query.definition.json`, `sql_schema.definition.json`, `sql_query_plugin.additional_settings.schema.json`, `sql_schema_plugin.additional_settings.schema.json`) + +#### New Features + +* **SQL Test Connection Button** + * Added a "Test Connection" button to the SQL Database Configuration section (Step 3) of the action wizard, allowing users to validate database connectivity before saving. + * Supports all database types: SQL Server, Azure SQL (with managed identity), PostgreSQL, MySQL, and SQLite. + * Shows inline success/failure alerts with a 15-second timeout cap and sanitized error messages. + * New backend endpoint: `POST /api/plugins/test-sql-connection`. + * (Ref: `route_backend_plugins.py`, `plugin_modal_stepper.js`, `_plugin_modal.html`) + +#### User Interface Enhancements + +* **Advanced Settings Collapse Toggle** + * Step 4 (Advanced) content is now hidden behind a collapsible toggle button ("Show Advanced Settings") instead of being displayed by default. Reduces visual noise for most users. + * For SQL action types, the redundant additional fields UI in Step 4 is hidden entirely since all SQL configuration is already handled in Step 3. + * Step 5 (Summary) no longer shows the raw additional fields JSON dump for SQL types, since that data is already shown in the SQL Database Configuration summary card. + * (Ref: `_plugin_modal.html`, `plugin_modal_stepper.js`) + ### **(v0.239.001)** #### New Features From 21daf808a7ee9edda6de43086ff95afa086e8408 Mon Sep 17 00:00:00 2001 From: Paul Lizer Date: Thu, 5 Mar 2026 21:49:32 -0500 Subject: [PATCH 2/2] adding user_id and created by modified by to group and global agents/actions and activity logs for all agent/action CRUD tasks --- application/single_app/config.py | 2 +- .../single_app/functions_activity_logging.py | 329 +++++++++++ .../single_app/functions_global_actions.py | 26 +- .../single_app/functions_global_agents.py | 29 +- .../single_app/functions_group_actions.py | 26 +- .../single_app/functions_group_agents.py | 26 +- .../single_app/functions_personal_actions.py | 17 +- .../single_app/functions_personal_agents.py | 28 +- .../single_app/route_backend_agents.py | 34 +- .../single_app/route_backend_plugins.py | 39 +- application/single_app/static/css/styles.css | 257 +++++++++ .../single_app/static/js/plugin_common.js | 63 ++- .../static/js/plugin_modal_stepper.js | 8 +- .../static/js/workspace/group_agents.js | 88 ++- .../static/js/workspace/group_plugins.js | 102 +++- .../static/js/workspace/view-utils.js | 523 ++++++++++++++++++ .../static/js/workspace/workspace_agents.js | 191 +++---- .../static/js/workspace/workspace_plugins.js | 50 +- .../templates/_agent_examples_modal.html | 9 +- .../templates/group_workspaces.html | 114 ++-- .../single_app/templates/workspace.html | 74 ++- docs/explanation/release_notes.md | 48 ++ 22 files changed, 1838 insertions(+), 245 deletions(-) create mode 100644 application/single_app/static/js/workspace/view-utils.js diff --git a/application/single_app/config.py b/application/single_app/config.py index a934fea7..294b1dca 100644 --- a/application/single_app/config.py +++ b/application/single_app/config.py @@ -94,7 +94,7 @@ EXECUTOR_TYPE = 'thread' EXECUTOR_MAX_WORKERS = 30 SESSION_TYPE = 'filesystem' -VERSION = "0.239.007" +VERSION = "0.239.013" SECRET_KEY = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production') diff --git a/application/single_app/functions_activity_logging.py b/application/single_app/functions_activity_logging.py index 2a653a47..efb6e780 100644 --- a/application/single_app/functions_activity_logging.py +++ b/application/single_app/functions_activity_logging.py @@ -1393,3 +1393,332 @@ def log_retention_policy_force_push( level=logging.ERROR ) debug_print(f"⚠️ Warning: Failed to log retention policy force push: {str(e)}") + + +# === AGENT & ACTION ACTIVITY LOGGING === + +def log_agent_creation( + user_id: str, + agent_id: str, + agent_name: str, + agent_display_name: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an agent creation activity. + + Args: + user_id: The ID of the user who created the agent + agent_id: The unique ID of the new agent + agent_name: The name of the agent + agent_display_name: The display name of the agent + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'agent_creation', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'agent', + 'operation': 'create', + 'entity': { + 'id': agent_id, + 'name': agent_name, + 'display_name': agent_display_name or agent_name + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Agent created: {agent_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Agent creation logged: {agent_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging agent creation: {str(e)}", + extra={'user_id': user_id, 'agent_id': agent_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log agent creation: {str(e)}") + + +def log_agent_update( + user_id: str, + agent_id: str, + agent_name: str, + agent_display_name: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an agent update activity. + + Args: + user_id: The ID of the user who updated the agent + agent_id: The unique ID of the agent + agent_name: The name of the agent + agent_display_name: The display name of the agent + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'agent_update', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'agent', + 'operation': 'update', + 'entity': { + 'id': agent_id, + 'name': agent_name, + 'display_name': agent_display_name or agent_name + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Agent updated: {agent_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Agent update logged: {agent_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging agent update: {str(e)}", + extra={'user_id': user_id, 'agent_id': agent_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log agent update: {str(e)}") + + +def log_agent_deletion( + user_id: str, + agent_id: str, + agent_name: str, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an agent deletion activity. + + Args: + user_id: The ID of the user who deleted the agent + agent_id: The unique ID of the agent + agent_name: The name of the agent + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'agent_deletion', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'agent', + 'operation': 'delete', + 'entity': { + 'id': agent_id, + 'name': agent_name + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Agent deleted: {agent_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Agent deletion logged: {agent_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging agent deletion: {str(e)}", + extra={'user_id': user_id, 'agent_id': agent_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log agent deletion: {str(e)}") + + +def log_action_creation( + user_id: str, + action_id: str, + action_name: str, + action_type: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an action/plugin creation activity. + + Args: + user_id: The ID of the user who created the action + action_id: The unique ID of the new action + action_name: The name of the action + action_type: The type of the action (e.g., 'openapi', 'sql_query') + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'action_creation', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'action', + 'operation': 'create', + 'entity': { + 'id': action_id, + 'name': action_name, + 'type': action_type + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Action created: {action_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Action creation logged: {action_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging action creation: {str(e)}", + extra={'user_id': user_id, 'action_id': action_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log action creation: {str(e)}") + + +def log_action_update( + user_id: str, + action_id: str, + action_name: str, + action_type: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an action/plugin update activity. + + Args: + user_id: The ID of the user who updated the action + action_id: The unique ID of the action + action_name: The name of the action + action_type: The type of the action + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'action_update', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'action', + 'operation': 'update', + 'entity': { + 'id': action_id, + 'name': action_name, + 'type': action_type + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Action updated: {action_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Action update logged: {action_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging action update: {str(e)}", + extra={'user_id': user_id, 'action_id': action_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log action update: {str(e)}") + + +def log_action_deletion( + user_id: str, + action_id: str, + action_name: str, + action_type: Optional[str] = None, + scope: str = 'personal', + group_id: Optional[str] = None +) -> None: + """ + Log an action/plugin deletion activity. + + Args: + user_id: The ID of the user who deleted the action + action_id: The unique ID of the action + action_name: The name of the action + action_type: The type of the action + scope: 'personal', 'group', or 'global' + group_id: The group ID (only for group scope) + """ + try: + activity_record = { + 'id': str(uuid.uuid4()), + 'user_id': user_id, + 'activity_type': 'action_deletion', + 'timestamp': datetime.utcnow().isoformat(), + 'created_at': datetime.utcnow().isoformat(), + 'entity_type': 'action', + 'operation': 'delete', + 'entity': { + 'id': action_id, + 'name': action_name, + 'type': action_type + }, + 'workspace_type': scope, + 'workspace_context': {} + } + if scope == 'group' and group_id: + activity_record['workspace_context']['group_id'] = group_id + + cosmos_activity_logs_container.create_item(body=activity_record) + log_event( + message=f"Action deleted: {action_name} ({scope}) by user {user_id}", + extra=activity_record, + level=logging.INFO + ) + debug_print(f"✅ Action deletion logged: {action_name} ({scope})") + except Exception as e: + log_event( + message=f"Error logging action deletion: {str(e)}", + extra={'user_id': user_id, 'action_id': action_id, 'scope': scope, 'error': str(e)}, + level=logging.ERROR + ) + debug_print(f"⚠️ Warning: Failed to log action deletion: {str(e)}") diff --git a/application/single_app/functions_global_actions.py b/application/single_app/functions_global_actions.py index 91f0d9f9..4d7293cd 100644 --- a/application/single_app/functions_global_actions.py +++ b/application/single_app/functions_global_actions.py @@ -60,12 +60,13 @@ def get_global_action(action_id, return_type=SecretReturnType.TRIGGER): return None -def save_global_action(action_data): +def save_global_action(action_data, user_id=None): """ Save or update a global action. Args: action_data (dict): Action data to save + user_id (str, optional): The user ID of the person performing the action Returns: dict: Saved action data or None if failed @@ -76,8 +77,27 @@ def save_global_action(action_data): action_data['id'] = str(uuid.uuid4()) # Add metadata action_data['is_global'] = True - action_data['created_at'] = datetime.utcnow().isoformat() - action_data['updated_at'] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + + # Check if this is a new action or an update to preserve created_by/created_at + existing_action = None + try: + existing_action = cosmos_global_actions_container.read_item( + item=action_data['id'], + partition_key=action_data['id'] + ) + except Exception: + pass + + if existing_action: + action_data['created_by'] = existing_action.get('created_by', user_id) + action_data['created_at'] = existing_action.get('created_at', now) + else: + action_data['created_by'] = user_id + action_data['created_at'] = now + action_data['modified_by'] = user_id + action_data['modified_at'] = now + action_data['updated_at'] = now print(f"💾 Saving global action: {action_data.get('name', 'Unknown')}") # Store secrets in Key Vault before upsert action_data = keyvault_plugin_save_helper(action_data, scope_value=action_data.get('id'), scope="global") diff --git a/application/single_app/functions_global_agents.py b/application/single_app/functions_global_agents.py index 5cf6a3d4..87976510 100644 --- a/application/single_app/functions_global_agents.py +++ b/application/single_app/functions_global_agents.py @@ -163,25 +163,46 @@ def get_global_agent(agent_id): return None -def save_global_agent(agent_data): +def save_global_agent(agent_data, user_id=None): """ Save or update a global agent. Args: agent_data (dict): Agent data to save + user_id (str, optional): The user ID of the person performing the action Returns: dict: Saved agent data or None if failed """ try: - user_id = get_current_user_id() + if user_id is None: + user_id = get_current_user_id() cleaned_agent = sanitize_agent_payload(agent_data) if 'id' not in cleaned_agent: cleaned_agent['id'] = str(uuid.uuid4()) cleaned_agent['is_global'] = True cleaned_agent['is_group'] = False - cleaned_agent['created_at'] = datetime.utcnow().isoformat() - cleaned_agent['updated_at'] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + + # Check if this is a new agent or an update to preserve created_by/created_at + existing_agent = None + try: + existing_agent = cosmos_global_agents_container.read_item( + item=cleaned_agent['id'], + partition_key=cleaned_agent['id'] + ) + except Exception: + pass + + if existing_agent: + cleaned_agent['created_by'] = existing_agent.get('created_by', user_id) + cleaned_agent['created_at'] = existing_agent.get('created_at', now) + else: + cleaned_agent['created_by'] = user_id + cleaned_agent['created_at'] = now + cleaned_agent['modified_by'] = user_id + cleaned_agent['modified_at'] = now + cleaned_agent['updated_at'] = now log_event( "Saving global agent.", extra={"agent_name": cleaned_agent.get('name', 'Unknown')}, diff --git a/application/single_app/functions_group_actions.py b/application/single_app/functions_group_actions.py index bc6aa4ea..c0d264b1 100644 --- a/application/single_app/functions_group_actions.py +++ b/application/single_app/functions_group_actions.py @@ -82,14 +82,36 @@ def get_group_action( return _clean_action(action, group_id, return_type) -def save_group_action(group_id: str, action_data: Dict[str, Any]) -> Dict[str, Any]: +def save_group_action(group_id: str, action_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group action entry.""" payload = dict(action_data) action_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = action_id payload["group_id"] = group_id - payload["last_updated"] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + payload["last_updated"] = now + + # Track who created/modified this action + existing_action = None + try: + existing_action = cosmos_group_actions_container.read_item( + item=action_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + pass + except Exception: + pass + + if existing_action: + payload["created_by"] = existing_action.get("created_by", user_id) + payload["created_at"] = existing_action.get("created_at", now) + else: + payload["created_by"] = user_id + payload["created_at"] = now + payload["modified_by"] = user_id + payload["modified_at"] = now payload.setdefault("name", "") payload.setdefault("displayName", payload.get("name", "")) diff --git a/application/single_app/functions_group_agents.py b/application/single_app/functions_group_agents.py index 8bf6f87c..7cbb8324 100644 --- a/application/single_app/functions_group_agents.py +++ b/application/single_app/functions_group_agents.py @@ -63,16 +63,38 @@ def get_group_agent(group_id: str, agent_id: str) -> Optional[Dict[str, Any]]: return None -def save_group_agent(group_id: str, agent_data: Dict[str, Any]) -> Dict[str, Any]: +def save_group_agent(group_id: str, agent_data: Dict[str, Any], user_id: Optional[str] = None) -> Dict[str, Any]: """Create or update a group agent entry.""" payload = sanitize_agent_payload(agent_data) agent_id = payload.get("id") or str(uuid.uuid4()) payload["id"] = agent_id payload["group_id"] = group_id - payload["last_updated"] = datetime.utcnow().isoformat() + now = datetime.utcnow().isoformat() + payload["last_updated"] = now payload["is_global"] = False payload["is_group"] = True + # Track who created/modified this agent + existing_agent = None + try: + existing_agent = cosmos_group_agents_container.read_item( + item=agent_id, + partition_key=group_id, + ) + except exceptions.CosmosResourceNotFoundError: + pass + except Exception: + pass + + if existing_agent: + payload["created_by"] = existing_agent.get("created_by", user_id) + payload["created_at"] = existing_agent.get("created_at", now) + else: + payload["created_by"] = user_id + payload["created_at"] = now + payload["modified_by"] = user_id + payload["modified_at"] = now + # Required/defaulted fields payload.setdefault("name", "") payload.setdefault("display_name", payload.get("name", "")) diff --git a/application/single_app/functions_personal_actions.py b/application/single_app/functions_personal_actions.py index 6345438e..91d849f3 100644 --- a/application/single_app/functions_personal_actions.py +++ b/application/single_app/functions_personal_actions.py @@ -113,15 +113,26 @@ def save_personal_action(user_id, action_data): existing_action = get_personal_action(user_id, action_data['name']) # Preserve existing ID if updating, or generate new ID if creating + now = datetime.utcnow().isoformat() if existing_action: - # Update existing action - preserve the original ID + # Update existing action - preserve the original ID and creation tracking action_data['id'] = existing_action['id'] + action_data['created_by'] = existing_action.get('created_by', user_id) + action_data['created_at'] = existing_action.get('created_at', now) elif 'id' not in action_data or not action_data['id']: # New action - generate UUID for ID action_data['id'] = str(uuid.uuid4()) - + action_data['created_by'] = user_id + action_data['created_at'] = now + else: + # Has an ID but no existing action found - treat as new + action_data['created_by'] = user_id + action_data['created_at'] = now + action_data['modified_by'] = user_id + action_data['modified_at'] = now + action_data['user_id'] = user_id - action_data['last_updated'] = datetime.utcnow().isoformat() + action_data['last_updated'] = now # Validate required fields required_fields = ['name', 'displayName', 'type', 'description'] diff --git a/application/single_app/functions_personal_agents.py b/application/single_app/functions_personal_agents.py index a4a5e47d..3c6c275e 100644 --- a/application/single_app/functions_personal_agents.py +++ b/application/single_app/functions_personal_agents.py @@ -128,9 +128,33 @@ def save_personal_agent(user_id, agent_data): cleaned_agent.setdefault(field, '') if 'id' not in cleaned_agent: cleaned_agent['id'] = str(f"{user_id}_{cleaned_agent.get('name', 'default')}") - + + # Check if this is a new agent or an update to preserve created_by/created_at + existing_agent = None + try: + existing_agent = cosmos_personal_agents_container.read_item( + item=cleaned_agent['id'], + partition_key=user_id + ) + except exceptions.CosmosResourceNotFoundError: + pass + except Exception: + pass + + now = datetime.utcnow().isoformat() + if existing_agent: + # Preserve original creation tracking + cleaned_agent['created_by'] = existing_agent.get('created_by', user_id) + cleaned_agent['created_at'] = existing_agent.get('created_at', now) + else: + # New agent + cleaned_agent['created_by'] = user_id + cleaned_agent['created_at'] = now + cleaned_agent['modified_by'] = user_id + cleaned_agent['modified_at'] = now + cleaned_agent['user_id'] = user_id - cleaned_agent['last_updated'] = datetime.utcnow().isoformat() + cleaned_agent['last_updated'] = now cleaned_agent['is_global'] = False cleaned_agent['is_group'] = False diff --git a/application/single_app/route_backend_agents.py b/application/single_app/route_backend_agents.py index 57097ee5..2f631af7 100644 --- a/application/single_app/route_backend_agents.py +++ b/application/single_app/route_backend_agents.py @@ -23,6 +23,11 @@ from functions_appinsights import log_event from json_schema_validation import validate_agent from swagger_wrapper import swagger_route, get_auth_security +from functions_activity_logging import ( + log_agent_creation, + log_agent_update, + log_agent_deletion, +) bpa = Blueprint('admin_agents', __name__) @@ -147,6 +152,18 @@ def set_user_agents(): for agent_name in agents_to_delete: delete_personal_agent(user_id, agent_name) + # Log individual agent activities + for agent in filtered_agents: + a_name = agent.get('name', '') + a_id = agent.get('id', '') + a_display = agent.get('display_name', a_name) + if a_name in current_agent_names: + log_agent_update(user_id=user_id, agent_id=a_id, agent_name=a_name, agent_display_name=a_display, scope='personal') + else: + log_agent_creation(user_id=user_id, agent_id=a_id, agent_name=a_name, agent_display_name=a_display, scope='personal') + for agent_name in agents_to_delete: + log_agent_deletion(user_id=user_id, agent_id=agent_name, agent_name=agent_name, scope='personal') + log_event("User agents updated", extra={"user_id": user_id, "agents_count": len(filtered_agents)}) return jsonify({'success': True}) @@ -175,6 +192,9 @@ def delete_user_agent(agent_name): # Delete from personal_agents container delete_personal_agent(user_id, agent_name) + # Log agent deletion activity + log_agent_deletion(user_id=user_id, agent_id=agent_to_delete.get('id', agent_name), agent_name=agent_name, scope='personal') + # Check if there are any agents left and if they match global_selected_agent remaining_agents = get_personal_agents(user_id) if len(remaining_agents) > 0: @@ -270,11 +290,12 @@ def create_group_agent_route(): cleaned_payload.pop(key, None) try: - saved = save_group_agent(active_group, cleaned_payload) + saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) except Exception as exc: debug_print('Failed to save group agent: %s', exc) return jsonify({'error': 'Unable to save agent'}), 500 + log_agent_creation(user_id=user_id, agent_id=saved.get('id', ''), agent_name=saved.get('name', ''), agent_display_name=saved.get('display_name', ''), scope='group', group_id=active_group) return jsonify(saved), 201 @@ -325,11 +346,12 @@ def update_group_agent_route(agent_id): return jsonify({'error': str(exc)}), 400 try: - saved = save_group_agent(active_group, cleaned_payload) + saved = save_group_agent(active_group, cleaned_payload, user_id=user_id) except Exception as exc: debug_print('Failed to update group agent %s: %s', agent_id, exc) return jsonify({'error': 'Unable to update agent'}), 500 + log_agent_update(user_id=user_id, agent_id=agent_id, agent_name=saved.get('name', ''), agent_display_name=saved.get('display_name', ''), scope='group', group_id=active_group) return jsonify(saved), 200 @@ -360,6 +382,7 @@ def delete_group_agent_route(agent_id): if not removed: return jsonify({'error': 'Agent not found'}), 404 + log_agent_deletion(user_id=user_id, agent_id=agent_id, agent_name=agent_id, scope='group', group_id=active_group) return jsonify({'message': 'Agent deleted'}), 200 # User endpoint to set selected agent (new model, not legacy default_agent) @@ -504,10 +527,11 @@ def add_agent(): cleaned_agent['id'] = '15b0c92a-741d-42ff-ba0b-367c7ee0c848' # Save to global agents container - result = save_global_agent(cleaned_agent) + result = save_global_agent(cleaned_agent, user_id=str(get_current_user_id())) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + log_agent_creation(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=cleaned_agent.get('name', ''), agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event("Agent added", extra={"action": "add", "agent": {k: v for k, v in cleaned_agent.items() if k != 'id'}, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) @@ -615,10 +639,11 @@ def edit_agent(agent_name): return jsonify({'error': 'Agent not found.'}), 404 # Save the updated agent - result = save_global_agent(cleaned_agent) + result = save_global_agent(cleaned_agent, user_id=str(get_current_user_id())) if not result: return jsonify({'error': 'Failed to save agent.'}), 500 + log_agent_update(user_id=str(get_current_user_id()), agent_id=cleaned_agent.get('id', ''), agent_name=agent_name, agent_display_name=cleaned_agent.get('display_name', ''), scope='global') log_event( f"Agent {agent_name} edited", extra={ @@ -660,6 +685,7 @@ def delete_agent(agent_name): if not success: return jsonify({'error': 'Failed to delete agent.'}), 500 + log_agent_deletion(user_id=str(get_current_user_id()), agent_id=agent_to_delete.get('id', ''), agent_name=agent_name, scope='global') log_event("Agent deleted", extra={"action": "delete", "agent_name": agent_name, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) diff --git a/application/single_app/route_backend_plugins.py b/application/single_app/route_backend_plugins.py index 084a5ee9..63c7854e 100644 --- a/application/single_app/route_backend_plugins.py +++ b/application/single_app/route_backend_plugins.py @@ -32,6 +32,11 @@ from functions_debug import debug_print from json_schema_validation import validate_plugin +from functions_activity_logging import ( + log_action_creation, + log_action_update, + log_action_deletion, +) def discover_plugin_types(): # Dynamically discover allowed plugin types from available plugin classes. @@ -345,6 +350,19 @@ def set_user_plugins(): except Exception as e: debug_print(f"Error saving personal actions for user {user_id}: {e}") return jsonify({'error': 'Failed to save plugins'}), 500 + + # Log individual action activities + for plugin in filtered_plugins: + p_name = plugin.get('name', '') + p_id = plugin.get('id', '') + p_type = plugin.get('type', '') + if p_name in current_action_names: + log_action_update(user_id=user_id, action_id=p_id, action_name=p_name, action_type=p_type, scope='personal') + else: + log_action_creation(user_id=user_id, action_id=p_id, action_name=p_name, action_type=p_type, scope='personal') + for plugin_name in (current_action_names - new_plugin_names): + log_action_deletion(user_id=user_id, action_id=plugin_name, action_name=plugin_name, scope='personal') + log_event("User plugins updated", extra={"user_id": user_id, "plugins_count": len(filtered_plugins)}) return jsonify({'success': True}) @@ -360,6 +378,7 @@ def delete_user_plugin(plugin_name): if not deleted: return jsonify({'error': 'Plugin not found.'}), 404 + log_action_deletion(user_id=user_id, action_id=plugin_name, action_name=plugin_name, scope='personal') log_event("User plugin deleted", extra={"user_id": user_id, "plugin_name": plugin_name}) return jsonify({'success': True}) @@ -474,11 +493,12 @@ def create_group_action_route(): payload['additionalFields'] = merged.get('additionalFields', payload.get('additionalFields', {})) try: - saved = save_group_action(active_group, payload) + saved = save_group_action(active_group, payload, user_id=user_id) except Exception as exc: debug_print('Failed to save group action: %s', exc) return jsonify({'error': 'Unable to save action'}), 500 + log_action_creation(user_id=user_id, action_id=saved.get('id', ''), action_name=saved.get('name', ''), action_type=saved.get('type', ''), scope='group', group_id=active_group) return jsonify(saved), 201 @@ -542,11 +562,12 @@ def update_group_action_route(action_id): merged['additionalFields'] = schema_merged.get('additionalFields', merged.get('additionalFields', {})) try: - saved = save_group_action(active_group, merged) + saved = save_group_action(active_group, merged, user_id=user_id) except Exception as exc: debug_print('Failed to update group action %s: %s', action_id, exc) return jsonify({'error': 'Unable to update action'}), 500 + log_action_update(user_id=user_id, action_id=action_id, action_name=saved.get('name', ''), action_type=saved.get('type', ''), scope='group', group_id=active_group) return jsonify(saved), 200 @@ -577,6 +598,7 @@ def delete_group_action_route(action_id): if not removed: return jsonify({'error': 'Action not found'}), 404 + log_action_deletion(user_id=user_id, action_id=action_id, action_name=action_id, scope='group', group_id=active_group) return jsonify({'message': 'Action deleted'}), 200 @bpap.route('/api/user/plugins/types', methods=['GET']) @@ -706,9 +728,10 @@ def add_plugin(): new_plugin['id'] = plugin_id # Save to global actions container - save_global_action(new_plugin) + save_global_action(new_plugin, user_id=str(get_current_user_id())) - log_event("Plugin added", extra={"action": "add", "plugin": new_plugin, "user": str(getattr(request, 'user', 'unknown'))}) + log_action_creation(user_id=str(get_current_user_id()), action_id=plugin_id, action_name=new_plugin.get('name', ''), action_type=new_plugin.get('type', ''), scope='global') + log_event("Plugin added", extra={"action": "add", "plugin": new_plugin, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) @@ -767,9 +790,10 @@ def edit_plugin(plugin_name): # Delete old and save updated if 'id' in found_plugin: delete_global_action(found_plugin['id']) - save_global_action(updated_plugin) + save_global_action(updated_plugin, user_id=str(get_current_user_id())) - log_event("Plugin edited", extra={"action": "edit", "plugin": updated_plugin, "user": str(getattr(request, 'user', 'unknown'))}) + log_action_update(user_id=str(get_current_user_id()), action_id=updated_plugin.get('id', ''), action_name=plugin_name, action_type=updated_plugin.get('type', ''), scope='global') + log_event("Plugin edited", extra={"action": "edit", "plugin": updated_plugin, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) @@ -810,7 +834,8 @@ def delete_plugin(plugin_name): if 'id' in plugin_to_delete: delete_global_action(plugin_to_delete['id']) - log_event("Plugin deleted", extra={"action": "delete", "plugin_name": plugin_name, "user": str(getattr(request, 'user', 'unknown'))}) + log_action_deletion(user_id=str(get_current_user_id()), action_id=plugin_to_delete.get('id', ''), action_name=plugin_name, action_type=plugin_to_delete.get('type', ''), scope='global') + log_event("Plugin deleted", extra={"action": "delete", "plugin_name": plugin_name, "user": str(get_current_user_id())}) # --- HOT RELOAD TRIGGER --- setattr(builtins, "kernel_reload_needed", True) return jsonify({'success': True}) diff --git a/application/single_app/static/css/styles.css b/application/single_app/static/css/styles.css index e537590d..eacc8859 100644 --- a/application/single_app/static/css/styles.css +++ b/application/single_app/static/css/styles.css @@ -502,6 +502,95 @@ main { flex-grow: 1; } +/* ============================================ + Item cards (agents/actions grid view) + ============================================ */ +.item-card { + cursor: default; + transition: all 0.3s ease; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + background-color: #ffffff; +} + +.item-card:hover { + border-color: #adb5bd; + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.item-card .card-title { + font-weight: 600; + font-size: 0.9rem; + color: #212529; +} + +.item-card .card-text { + color: #6c757d; + font-size: 0.8rem; + line-height: 1.4; +} + +.item-card .item-card-icon { + color: #0d6efd; +} + +.item-card .item-card-buttons { + border-top: 1px solid #f0f0f0; + padding-top: 0.5rem; +} + +/* Dark mode for item cards */ +[data-bs-theme="dark"] .item-card { + background-color: #343a40; + border: 1px solid #495057; + color: #e9ecef; +} + +[data-bs-theme="dark"] .item-card:hover { + background-color: #3d444b; + border-color: #6c757d; +} + +[data-bs-theme="dark"] .item-card .card-title { + color: #e9ecef; +} + +[data-bs-theme="dark"] .item-card .card-text { + color: #adb5bd; +} + +[data-bs-theme="dark"] .item-card .item-card-icon { + color: #6ea8fe; +} + +[data-bs-theme="dark"] .item-card .item-card-buttons { + border-top-color: #495057; +} + +/* Improved table column layout for agents and actions */ +.item-list-table th:nth-child(1), +.item-list-table td:nth-child(1) { + width: 28%; + min-width: 140px; +} + +.item-list-table th:nth-child(2), +.item-list-table td:nth-child(2) { + width: 47%; + max-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item-list-table th:nth-child(3), +.item-list-table td:nth-child(3) { + width: 25%; + min-width: 160px; + white-space: nowrap; +} + /* Connection type buttons */ .connection-type-btn { border: 2px solid #dee2e6; @@ -854,3 +943,171 @@ main { [data-bs-theme="dark"] .message-content a:visited { color: #b399ff !important; /* Purple-ish for visited links */ } + +/* ============================================ + Rendered Markdown — table & code block styles + Shared by agent detail view, template preview, + and any non-chat area that renders Markdown. + ============================================ */ + +/* --- Tables --- */ +.rendered-markdown table { + width: 100%; + max-width: 100%; + margin: 0.75rem 0; + border-collapse: collapse; + border-spacing: 0; + border: 1px solid #dee2e6; + border-radius: 0.375rem; + overflow: hidden; + background-color: var(--bs-body-bg); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + font-size: 0.875rem; + display: block; + overflow-x: auto; + white-space: nowrap; + -webkit-overflow-scrolling: touch; +} + +@media (min-width: 768px) { + .rendered-markdown table { + display: table; + white-space: normal; + } +} + +.rendered-markdown table th, +.rendered-markdown table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #dee2e6; + border-right: 1px solid #dee2e6; + text-align: left; + vertical-align: top; + word-wrap: break-word; + line-height: 1.4; +} + +.rendered-markdown table th:last-child, +.rendered-markdown table td:last-child { + border-right: none; +} + +.rendered-markdown table thead th { + background-color: #f8f9fa; + font-weight: 600; + color: #495057; + border-bottom: 2px solid #dee2e6; +} + +.rendered-markdown table tbody tr:nth-child(even) { + background-color: rgba(0, 0, 0, 0.02); +} + +.rendered-markdown table tbody tr:hover { + background-color: rgba(0, 0, 0, 0.04); + transition: background-color 0.15s ease-in-out; +} + +.rendered-markdown table th[align="center"], +.rendered-markdown table td[align="center"] { + text-align: center; +} + +.rendered-markdown table th[align="right"], +.rendered-markdown table td[align="right"] { + text-align: right; +} + +/* Dark mode tables */ +[data-bs-theme="dark"] .rendered-markdown table { + border-color: #495057; + background-color: var(--bs-dark); + color: #e9ecef; +} + +[data-bs-theme="dark"] .rendered-markdown table th, +[data-bs-theme="dark"] .rendered-markdown table td { + border-color: #495057; +} + +[data-bs-theme="dark"] .rendered-markdown table thead th { + background-color: #343a40; + color: #e9ecef; + border-bottom-color: #495057; +} + +[data-bs-theme="dark"] .rendered-markdown table tbody tr:nth-child(even) { + background-color: rgba(255, 255, 255, 0.05); +} + +[data-bs-theme="dark"] .rendered-markdown table tbody tr:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.rendered-markdown table code { + background-color: rgba(0, 0, 0, 0.1); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.8em; +} + +[data-bs-theme="dark"] .rendered-markdown table code { + background-color: rgba(255, 255, 255, 0.1); +} + +/* --- Code blocks --- */ +.rendered-markdown pre, +.rendered-markdown pre[class*="language-"] { + overflow-x: auto; + max-width: 100%; + width: 100%; + box-sizing: border-box; + display: block; + white-space: pre; + background-color: #1e1e1e; + color: #d4d4d4; + border-radius: 0.375rem; + padding: 1rem; + margin: 0.75rem 0; + font-size: 0.85rem; + line-height: 1.5; +} + +.rendered-markdown pre code { + display: block; + min-width: 0; + max-width: 100%; + overflow-x: auto; + white-space: pre; + background: transparent; + color: inherit; + padding: 0; + font-size: inherit; +} + +/* Inline code */ +.rendered-markdown code:not(pre code) { + background-color: rgba(0, 0, 0, 0.06); + padding: 0.15rem 0.35rem; + border-radius: 0.25rem; + font-size: 0.85em; + color: #d63384; +} + +[data-bs-theme="dark"] .rendered-markdown code:not(pre code) { + background-color: rgba(255, 255, 255, 0.1); + color: #e685b5; +} + +/* Blockquotes */ +.rendered-markdown blockquote { + border-left: 4px solid #dee2e6; + padding-left: 1em; + color: #6c757d; + margin: 0.75rem 0; +} + +[data-bs-theme="dark"] .rendered-markdown blockquote { + border-left-color: #495057; + color: #adb5bd; +} diff --git a/application/single_app/static/js/plugin_common.js b/application/single_app/static/js/plugin_common.js index e40158b9..29a88a24 100644 --- a/application/single_app/static/js/plugin_common.js +++ b/application/single_app/static/js/plugin_common.js @@ -2,6 +2,10 @@ // Shared logic for admin_plugins.js and workspace_plugins.js // Exports: functions for modal field handling, validation, label toggling, table rendering, and plugin CRUD import { showToast } from "./chat/chat-toast.js" +import { + humanizeName, truncateDescription, + openViewModal, createActionCard +} from './workspace/view-utils.js'; // Fetch merged plugin settings from backend given type and current settings export async function fetchAndMergePluginSettings(pluginType, currentSettings = {}) { @@ -60,8 +64,7 @@ export function escapeHtml(str) { } // Render plugins table (parameterized for tbody selector and button handlers) -export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, ensureTable = true, isAdmin = false}) { - console.log('Rendering plugins table with %d plugins', plugins.length); +export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, onView, ensureTable = true, isAdmin = false}) { // Optionally ensure the table is present before rendering if (ensureTable) { ensurePluginsTableInRoot(); @@ -75,29 +78,33 @@ export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, en plugins.forEach(plugin => { const tr = document.createElement('tr'); const safeName = escapeHtml(plugin.name); - const safeDisplayName = escapeHtml(plugin.display_name || plugin.name); - const safeDesc = escapeHtml(plugin.description || 'No description available'); + const displayName = humanizeName(plugin.display_name || plugin.name); + const safeDisplayName = escapeHtml(displayName); + const description = plugin.description || 'No description available'; + const truncatedDesc = escapeHtml(truncateDescription(description, 90)); let actionButtons = ''; let globalBadge = plugin.is_global ? ' Global' : ''; - // Show action buttons for: - // - Admin context: all actions (global and personal) - // - User context: only personal actions (not global) + // View button always shown + let viewButton = ``; + + // Edit/Delete buttons based on context + let editDeleteButtons = ''; if (isAdmin || !plugin.is_global) { - actionButtons = ` -
+ editDeleteButtons = ` -
- `; + `; } + actionButtons = `
${viewButton}${editDeleteButtons}
`; tr.innerHTML = ` - ${safeDisplayName}${globalBadge} - ${safeDesc} + ${safeDisplayName}${globalBadge} + ${truncatedDesc} ${actionButtons} `; tbody.appendChild(tr); @@ -109,6 +116,34 @@ export function renderPluginsTable({plugins, tbodySelector, onEdit, onDelete, en tbody.querySelectorAll('.delete-plugin-btn').forEach(btn => { btn.onclick = () => onDelete(btn.getAttribute('data-plugin-name')); }); + tbody.querySelectorAll('.view-plugin-btn').forEach(btn => { + btn.onclick = () => { + if (onView) { + onView(btn.getAttribute('data-plugin-name')); + } + }; + }); +} + +// Render plugins grid (card-based view) +export function renderPluginsGrid({plugins, containerSelector, onEdit, onDelete, onView, isAdmin = false}) { + const container = document.querySelector(containerSelector); + if (!container) return; + container.innerHTML = ''; + if (!plugins.length) { + container.innerHTML = '
No actions found.
'; + return; + } + plugins.forEach(plugin => { + const card = createActionCard(plugin, { + onView: (p) => { if (onView) onView(p.name); }, + onEdit: (p) => onEdit(p.name), + onDelete: (p) => onDelete(p.name), + canManage: isAdmin || !plugin.is_global, + isAdmin + }); + container.appendChild(card); + }); } // Toggle auth fields and labels (parameterized for DOM elements) diff --git a/application/single_app/static/js/plugin_modal_stepper.js b/application/single_app/static/js/plugin_modal_stepper.js index 6729124f..aa5b4e01 100644 --- a/application/single_app/static/js/plugin_modal_stepper.js +++ b/application/single_app/static/js/plugin_modal_stepper.js @@ -1,6 +1,7 @@ // plugin_modal_stepper.js // Multi-step modal functionality for action/plugin creation import { showToast } from "./chat/chat-toast.js"; +import { getTypeIcon } from "./workspace/view-utils.js"; // Action types hidden from the creation UI (backend plugins remain intact) const HIDDEN_ACTION_TYPES = ['sql_schema', 'ui_test', 'queue_storage', 'blob_storage', 'embedding_model']; @@ -282,10 +283,15 @@ export class PluginModalStepper { description.substring(0, maxLength) + '...' : description; const needsTruncation = description.length > maxLength; + const iconClass = getTypeIcon(type.type || type.name); + col.innerHTML = `
-
${this.escapeHtml(displayName)}
+
+ +
${this.escapeHtml(displayName)}
+

${this.escapeHtml(truncatedDescription)} ${needsTruncation ? ` diff --git a/application/single_app/static/js/workspace/group_agents.js b/application/single_app/static/js/workspace/group_agents.js index 56157d65..608f029e 100644 --- a/application/single_app/static/js/workspace/group_agents.js +++ b/application/single_app/static/js/workspace/group_agents.js @@ -4,16 +4,23 @@ import { showToast } from "../chat/chat-toast.js"; import * as agentsCommon from "../agents_common.js"; import { AgentModalStepper } from "../agent_modal_stepper.js"; +import { + humanizeName, truncateDescription, escapeHtml as escapeHtmlUtil, + setupViewToggle, switchViewContainers, openViewModal, createAgentCard +} from './view-utils.js'; const tableBody = document.getElementById("group-agents-table-body"); const errorContainer = document.getElementById("group-agents-error"); const searchInput = document.getElementById("group-agents-search"); const createButton = document.getElementById("create-group-agent-btn"); const permissionWarning = document.getElementById("group-agents-permission-warning"); +const agentsListView = document.getElementById("group-agents-list-view"); +const agentsGridView = document.getElementById("group-agents-grid-view"); let agents = []; let filteredAgents = []; let agentStepper = null; +let currentViewMode = 'list'; let currentContext = window.groupWorkspaceContext || { activeGroupId: null, activeGroupName: "", @@ -21,14 +28,7 @@ let currentContext = window.groupWorkspaceContext || { }; function escapeHtml(value) { - if (!value) return ""; - return value.replace(/[&<>"']/g, (char) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }[char] || char)); + return escapeHtmlUtil(value); } function canManageAgents() { @@ -46,6 +46,7 @@ function groupAllowsModifications() { } function truncateName(name, maxLength = 18) { + // Kept for backward compat; prefer humanizeName for display if (!name || name.length <= maxLength) return name || ""; return `${name.substring(0, maxLength)}…`; } @@ -114,32 +115,61 @@ function renderAgentsTable(list) { list.forEach((agent) => { const tr = document.createElement("tr"); - const displayName = truncateName(agent.display_name || agent.displayName || agent.name || ""); - const description = escapeHtml(agent.description || "No description available."); + const rawName = agent.display_name || agent.displayName || agent.name || ""; + const displayName = humanizeName(rawName); + const fullDesc = agent.description || "No description available."; + const shortDesc = truncateDescription(fullDesc, 90); let actionsHtml = ` + `; if (canManage) { actionsHtml += ` - - `; } tr.innerHTML = ` - ${escapeHtml(displayName)} - ${description} + ${escapeHtml(displayName)} + ${escapeHtml(shortDesc)} ${actionsHtml}`; tableBody.appendChild(tr); }); } +function renderAgentsGrid(list) { + if (!agentsGridView) return; + agentsGridView.innerHTML = ''; + + if (!list.length) { + agentsGridView.innerHTML = '

No group agents found.
'; + return; + } + + const canManage = canManageAgents() && groupAllowsModifications(); + list.forEach(agent => { + const col = createAgentCard(agent, { + onChat: a => chatWithGroupAgent(a.name || a), + onView: a => openGroupAgentViewModal(a), + onEdit: canManage ? a => { + const found = agents.find(x => x.id === (a.id || a.name || a) || x.name === (a.name || a)); + openAgentModal(found || null); + } : null, + onDelete: canManage ? a => deleteGroupAgent(a.id || a.name || a) : null + }); + agentsGridView.appendChild(col); + }); +} + function filterAgents(term) { if (!term) { filteredAgents = agents.slice(); @@ -152,6 +182,23 @@ function filterAgents(term) { }); } renderAgentsTable(filteredAgents); + renderAgentsGrid(filteredAgents); +} + +// Open the view modal for a group agent with Chat/Edit/Delete actions +function openGroupAgentViewModal(agent) { + const canManage = canManageAgents() && groupAllowsModifications(); + const callbacks = { + onChat: (a) => chatWithGroupAgent(a.name) + }; + if (canManage) { + callbacks.onEdit = (a) => { + const found = agents.find(x => x.id === a.id || x.name === a.name); + openAgentModal(found || a); + }; + callbacks.onDelete = (a) => deleteGroupAgent(a.id || a.name); + } + openViewModal(agent, 'agent', callbacks); } function overrideAgentStepper(stepper) { @@ -382,6 +429,14 @@ async function chatWithGroupAgent(agentName) { } function handleTableClick(event) { + const viewBtn = event.target.closest(".view-group-agent-btn"); + if (viewBtn) { + const agentName = viewBtn.dataset.agentName; + const agent = agents.find(a => a.name === agentName); + if (agent) openGroupAgentViewModal(agent); + return; + } + const chatBtn = event.target.closest(".chat-group-agent-btn"); if (chatBtn) { const agentName = chatBtn.dataset.agentName; @@ -429,6 +484,11 @@ function initialize() { updatePermissionUI(); bindEventHandlers(); + setupViewToggle('groupAgents', 'groupAgentsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, agentsListView, agentsGridView); + }); + if (document.getElementById("group-agents-tab-btn")?.classList.contains("active")) { fetchGroupAgents(); } diff --git a/application/single_app/static/js/workspace/group_plugins.js b/application/single_app/static/js/workspace/group_plugins.js index 60a7f42e..8acdf5bd 100644 --- a/application/single_app/static/js/workspace/group_plugins.js +++ b/application/single_app/static/js/workspace/group_plugins.js @@ -3,6 +3,10 @@ import { ensurePluginsTableInRoot, validatePluginManifest } from "../plugin_common.js"; import { showToast } from "../chat/chat-toast.js"; +import { + humanizeName, truncateDescription, escapeHtml as escapeHtmlUtil, + setupViewToggle, switchViewContainers, openViewModal, createActionCard +} from './view-utils.js'; const root = document.getElementById("group-plugins-root"); const permissionWarning = document.getElementById("group-plugins-permission-warning"); @@ -11,6 +15,7 @@ let plugins = []; let filteredPlugins = []; let templateReady = false; let listenersBound = false; +let currentViewMode = 'list'; let currentContext = window.groupWorkspaceContext || { activeGroupId: null, activeGroupName: "", @@ -18,14 +23,7 @@ let currentContext = window.groupWorkspaceContext || { }; function escapeHtml(value) { - if (!value) return ""; - return value.replace(/[&<>"']/g, (char) => ({ - "&": "&", - "<": "<", - ">": ">", - '"': """, - "'": "'" - }[char] || char)); + return escapeHtmlUtil(value); } function canManagePlugins() { @@ -66,6 +64,14 @@ function bindRootEvents() { }); root.addEventListener("click", async (event) => { + const viewBtn = event.target.closest(".view-group-plugin-btn"); + if (viewBtn) { + const pluginId = viewBtn.dataset.pluginId; + const plugin = plugins.find(x => x.id === pluginId || x.name === pluginId); + if (plugin) openGroupPluginViewModal(plugin); + return; + } + const createBtn = event.target.closest("#create-group-plugin-btn"); if (createBtn) { event.preventDefault(); @@ -148,23 +154,28 @@ function renderPluginsTable(list) { const canManage = canManagePlugins() && groupAllowsModifications(); list.forEach((plugin) => { const tr = document.createElement("tr"); - const displayName = plugin.displayName || plugin.display_name || plugin.name || ""; - const description = plugin.description || "No description available."; + const rawName = plugin.displayName || plugin.display_name || plugin.name || ""; + const displayName = humanizeName(rawName); + const fullDesc = plugin.description || "No description available."; + const shortDesc = truncateDescription(fullDesc, 90); const isGlobal = Boolean(plugin.is_global); - let actionsHtml = ""; + // View button always visible + let actionsHtml = ` + `; + if (canManage && !isGlobal) { - actionsHtml = ` -
- - -
`; + actionsHtml += ` + + `; } else if (canManage && isGlobal) { - actionsHtml = "Managed globally"; + actionsHtml += `Managed globally`; } const titleHtml = isGlobal @@ -172,14 +183,36 @@ function renderPluginsTable(list) { : escapeHtml(displayName); tr.innerHTML = ` - ${titleHtml} - ${escapeHtml(description)} + ${titleHtml} + ${escapeHtml(shortDesc)} ${actionsHtml}`; tbody.appendChild(tr); }); } +function renderPluginsGrid(list) { + const gridView = document.getElementById('group-plugins-grid-view'); + if (!gridView) return; + gridView.innerHTML = ''; + + if (!list.length) { + gridView.innerHTML = '
No group actions found.
'; + return; + } + + const canManage = canManagePlugins() && groupAllowsModifications(); + list.forEach(plugin => { + const isGlobal = Boolean(plugin.is_global); + const col = createActionCard(plugin, { + onView: p => openGroupPluginViewModal(p), + onEdit: (canManage && !isGlobal) ? p => openPluginModal(p.id || p.name) : null, + onDelete: (canManage && !isGlobal) ? p => deleteGroupPlugin(p.id || p.name) : null + }); + gridView.appendChild(col); + }); +} + function filterPlugins(term) { if (!term) { filteredPlugins = plugins.slice(); @@ -192,6 +225,19 @@ function filterPlugins(term) { }); } renderPluginsTable(filteredPlugins); + renderPluginsGrid(filteredPlugins); +} + +// Open the view modal for a group action with Edit/Delete actions +function openGroupPluginViewModal(plugin) { + const canManage = canManagePlugins() && groupAllowsModifications(); + const isGlobal = Boolean(plugin.is_global); + const callbacks = {}; + if (canManage && !isGlobal) { + callbacks.onEdit = (p) => openPluginModal(p.id || p.name); + callbacks.onDelete = (p) => deleteGroupPlugin(p.id || p.name); + } + openViewModal(plugin, 'action', callbacks); } async function fetchGroupPlugins() { @@ -220,7 +266,17 @@ async function fetchGroupPlugins() { filteredPlugins = plugins.slice(); renderPluginsTable(filteredPlugins); + renderPluginsGrid(filteredPlugins); updatePermissionUI(); + + // Set up view toggle (only once after template is in DOM) + setupViewToggle('groupPlugins', 'groupPluginsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, + document.getElementById('group-plugins-list-view'), + document.getElementById('group-plugins-grid-view') + ); + }); } catch (error) { console.error("Error loading group actions:", error); renderError(error.message || "Unable to load group actions."); diff --git a/application/single_app/static/js/workspace/view-utils.js b/application/single_app/static/js/workspace/view-utils.js new file mode 100644 index 00000000..3b78bc15 --- /dev/null +++ b/application/single_app/static/js/workspace/view-utils.js @@ -0,0 +1,523 @@ +// view-utils.js +// Shared utilities for list/grid view toggle, name humanization, and view modal +// Used by personal and group agents/actions workspace modules + +/** + * Convert a technical name to a human-readable display name. + * Handles underscores, camelCase, PascalCase, and consecutive uppercase. + * Examples: + * "sql_query" → "Sql Query" + * "myAgentName" → "My Agent Name" + * "OpenAPIPlugin" → "Open API Plugin" + * "log_analytics" → "Log Analytics" + */ +export function humanizeName(name) { + if (!name) return ""; + // Replace underscores and hyphens with spaces + let result = name.replace(/[_-]/g, " "); + // Insert space before uppercase letters that follow lowercase letters (camelCase) + result = result.replace(/([a-z])([A-Z])/g, "$1 $2"); + // Insert space between consecutive uppercase followed by lowercase (e.g., "APIPlugin" → "API Plugin") + result = result.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2"); + // Capitalize first letter of each word + result = result.replace(/\b\w/g, (c) => c.toUpperCase()); + // Collapse multiple spaces + result = result.replace(/\s+/g, " ").trim(); + return result; +} + +/** + * Truncate a description string to maxLen characters, appending "…" if truncated. + */ +export function truncateDescription(text, maxLen = 100) { + if (!text) return ""; + if (text.length <= maxLen) return text; + return text.substring(0, maxLen).trimEnd() + "…"; +} + +/** + * Escape HTML entities to prevent XSS. + */ +export function escapeHtml(str) { + if (!str) return ""; + return str.replace(/[&<>"']/g, (c) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) + ); +} + +/** + * Get an appropriate Bootstrap icon class for an action/plugin type. + */ +export function getTypeIcon(type) { + if (!type) return "bi-lightning-charge"; + const t = type.toLowerCase(); + if (t.includes("sql")) return "bi-database"; + if (t.includes("openapi")) return "bi-globe"; + if (t.includes("log_analytics")) return "bi-graph-up"; + if (t.includes("msgraph")) return "bi-microsoft"; + if (t.includes("databricks")) return "bi-bricks"; + if (t.includes("http") || t.includes("smart_http")) return "bi-cloud-arrow-up"; + if (t.includes("azure_function")) return "bi-lightning"; + if (t.includes("blob")) return "bi-file-earmark"; + if (t.includes("queue")) return "bi-inbox"; + if (t.includes("embedding")) return "bi-vector-pen"; + if (t.includes("fact_memory")) return "bi-brain"; + if (t.includes("math")) return "bi-calculator"; + if (t.includes("text")) return "bi-fonts"; + if (t.includes("time")) return "bi-clock"; + return "bi-lightning-charge"; +} + +/** + * Create the HTML string for a list/grid view toggle button group. + * @param {string} prefix - Unique prefix for element IDs (e.g., "agents", "plugins", "group-agents") + * @returns {string} HTML string + */ +export function createViewToggleHtml(prefix) { + return ` +
+ + + + +
`; +} + +/** + * Set up view toggle event listeners and restore saved preference. + * @param {string} prefix - Unique prefix matching createViewToggleHtml + * @param {string} storageKey - localStorage key for persistence + * @param {function} onSwitch - Callback receiving 'list' or 'grid' + */ +export function setupViewToggle(prefix, storageKey, onSwitch) { + const listRadio = document.getElementById(`${prefix}-view-list`); + const gridRadio = document.getElementById(`${prefix}-view-grid`); + if (!listRadio || !gridRadio) return; + + listRadio.addEventListener("change", () => { + if (listRadio.checked) { + localStorage.setItem(storageKey, "list"); + onSwitch("list"); + } + }); + + gridRadio.addEventListener("change", () => { + if (gridRadio.checked) { + localStorage.setItem(storageKey, "grid"); + onSwitch("grid"); + } + }); + + // Restore saved preference + const saved = localStorage.getItem(storageKey); + if (saved === "grid") { + gridRadio.checked = true; + listRadio.checked = false; + onSwitch("grid"); + } else { + onSwitch("list"); + } +} + +/** + * Toggle visibility of list and grid containers. + * @param {string} mode - 'list' or 'grid' + * @param {HTMLElement} listContainer - The list/table container element + * @param {HTMLElement} gridContainer - The grid container element + */ +export function switchViewContainers(mode, listContainer, gridContainer) { + if (listContainer) { + listContainer.classList.toggle("d-none", mode !== "list"); + } + if (gridContainer) { + gridContainer.classList.toggle("d-none", mode !== "grid"); + } +} + +// ============================================================================ +// VIEW MODAL — Lightweight read-only detail view +// ============================================================================ + +/** + * Open a read-only view modal for an agent or action. + * @param {object} item - The agent or action data object + * @param {'agent'|'action'} type - What kind of item this is + * @param {object} [callbacks] - Optional action callbacks { onChat, onEdit, onDelete } + */ +export function openViewModal(item, type, callbacks = {}) { + const modalEl = document.getElementById("item-view-modal"); + if (!modalEl) return; + + const titleEl = modalEl.querySelector(".modal-title"); + const bodyEl = modalEl.querySelector(".modal-body"); + const footerEl = modalEl.querySelector(".modal-footer"); + if (!titleEl || !bodyEl || !footerEl) return; + + if (type === "agent") { + titleEl.textContent = "Agent Details"; + bodyEl.innerHTML = buildAgentViewHtml(item); + } else { + titleEl.textContent = "Action Details"; + bodyEl.innerHTML = buildActionViewHtml(item); + } + + // Build footer buttons dynamically + footerEl.innerHTML = ''; + const { onChat, onEdit, onDelete } = callbacks; + + if (onChat && typeof onChat === 'function') { + const chatBtn = document.createElement('button'); + chatBtn.type = 'button'; + chatBtn.className = 'btn btn-primary'; + chatBtn.innerHTML = 'Chat'; + chatBtn.addEventListener('click', () => { + bootstrap.Modal.getInstance(modalEl)?.hide(); + onChat(item); + }); + footerEl.appendChild(chatBtn); + } + + if (onEdit && typeof onEdit === 'function') { + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'btn btn-outline-secondary'; + editBtn.innerHTML = 'Edit'; + editBtn.addEventListener('click', () => { + bootstrap.Modal.getInstance(modalEl)?.hide(); + onEdit(item); + }); + footerEl.appendChild(editBtn); + } + + if (onDelete && typeof onDelete === 'function') { + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'btn btn-outline-danger'; + delBtn.innerHTML = 'Delete'; + delBtn.addEventListener('click', () => { + bootstrap.Modal.getInstance(modalEl)?.hide(); + onDelete(item); + }); + footerEl.appendChild(delBtn); + } + + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'btn btn-secondary'; + closeBtn.textContent = 'Close'; + closeBtn.setAttribute('data-bs-dismiss', 'modal'); + footerEl.appendChild(closeBtn); + + const modal = new bootstrap.Modal(modalEl); + modal.show(); +} + +function buildAgentViewHtml(agent) { + const displayName = escapeHtml(agent.display_name || agent.displayName || agent.name || ""); + const name = escapeHtml(agent.name || ""); + const description = escapeHtml(agent.description || "No description available."); + const model = escapeHtml(agent.azure_openai_gpt_deployment || agent.model || "Default"); + const agentType = agent.agent_type === "aifoundry" ? "Azure AI Foundry" : "Local (Semantic Kernel)"; + const rawInstructions = agent.instructions || "No instructions defined."; + // Render instructions as Markdown (marked + DOMPurify are loaded globally in base.html) + const renderedInstructions = (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') + ? DOMPurify.sanitize(marked.parse(rawInstructions)) + : escapeHtml(rawInstructions); + const isGlobal = agent.is_global; + const scopeBadge = isGlobal + ? 'Global' + : 'Personal'; + + return ` +
+
+ Basic Information +
+
+
+
+ + ${displayName} +
+
+ + ${name} +
+
+ + ${scopeBadge} +
+
+ + ${escapeHtml(agentType)} +
+
+ + ${description} +
+
+
+
+
+
+ Model Configuration +
+
+
+
+ + ${model} +
+
+
+
+
+
+ Instructions +
+
+
+${renderedInstructions} +
+
+
`; +} + +function buildActionViewHtml(action) { + const displayName = escapeHtml(action.display_name || action.displayName || action.name || ""); + const name = escapeHtml(action.name || ""); + const description = escapeHtml(action.description || "No description available."); + const type = escapeHtml(action.type || "unknown"); + const typeIcon = getTypeIcon(action.type); + const authType = escapeHtml(formatAuthType(action.auth?.type || action.auth_type || "")); + const endpoint = escapeHtml(action.endpoint || action.base_url || ""); + const isGlobal = action.is_global; + const scopeBadge = isGlobal + ? 'Global' + : 'Personal'; + + let configHtml = ""; + if (endpoint) { + configHtml = ` +
+
+ Configuration +
+
+
+
+ + ${endpoint} +
+
+ + ${authType || "None"} +
+
+
+
`; + } + + return ` +
+
+ Basic Information +
+
+
+
+ + ${displayName} +
+
+ + ${name} +
+
+ + ${humanizeName(type)} +
+
+ + ${scopeBadge} +
+
+ + ${description} +
+
+
+
+ ${configHtml}`; +} + +function formatAuthType(type) { + if (!type) return ""; + const map = { + "key": "API Key", + "identity": "Managed Identity", + "user": "User (Delegated)", + "servicePrincipal": "Service Principal", + "connection_string": "Connection String", + "basic": "Basic Auth", + "username_password": "Username / Password", + "NoAuth": "No Authentication" + }; + return map[type] || type; +} + +// ============================================================================ +// GRID CARD RENDERERS +// ============================================================================ + +/** + * Create a grid card element for an agent. + * @param {object} agent - Agent data object + * @param {object} options - { onChat, onView, onEdit, onDelete, canManage, isGroup } + * @returns {HTMLElement} + */ +export function createAgentCard(agent, options = {}) { + const { onChat, onView, onEdit, onDelete, canManage = false, isGroup = false } = options; + const col = document.createElement("div"); + col.className = "col-sm-6 col-md-4 col-lg-3"; + + const displayName = humanizeName(agent.display_name || agent.displayName || agent.name || ""); + const description = agent.description || "No description available."; + const isGlobal = agent.is_global; + + let badgeHtml = ""; + if (isGlobal) { + badgeHtml = 'Global'; + } + + let buttonsHtml = ` + + `; + + if (canManage && !isGlobal) { + buttonsHtml += ` + + `; + } + + col.innerHTML = ` +
+
+
+ +
+
${escapeHtml(displayName)}${badgeHtml}
+

${escapeHtml(truncateDescription(description, 120))}

+
+ ${buttonsHtml} +
+
+
`; + + // Bind button events + const chatBtn = col.querySelector(".item-card-chat-btn"); + const viewBtn = col.querySelector(".item-card-view-btn"); + const editBtn = col.querySelector(".item-card-edit-btn"); + const deleteBtn = col.querySelector(".item-card-delete-btn"); + + if (chatBtn && onChat) chatBtn.addEventListener("click", (e) => { e.stopPropagation(); onChat(agent); }); + if (viewBtn && onView) viewBtn.addEventListener("click", (e) => { e.stopPropagation(); onView(agent); }); + if (editBtn && onEdit) editBtn.addEventListener("click", (e) => { e.stopPropagation(); onEdit(agent); }); + if (deleteBtn && onDelete) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); onDelete(agent); }); + + // Clicking anywhere on the card opens the detail view + const cardEl = col.querySelector(".item-card"); + if (cardEl && onView) { + cardEl.style.cursor = "pointer"; + cardEl.addEventListener("click", () => onView(agent)); + } + + return col; +} + +/** + * Create a grid card element for an action/plugin. + * @param {object} plugin - Action/plugin data object + * @param {object} options - { onView, onEdit, onDelete, canManage, isAdmin } + * @returns {HTMLElement} + */ +export function createActionCard(plugin, options = {}) { + const { onView, onEdit, onDelete, canManage = true, isAdmin = false } = options; + const col = document.createElement("div"); + col.className = "col-sm-6 col-md-4 col-lg-3"; + + const displayName = humanizeName(plugin.display_name || plugin.displayName || plugin.name || ""); + const description = plugin.description || "No description available."; + const type = plugin.type || ""; + const typeIcon = getTypeIcon(type); + const isGlobal = plugin.is_global; + + let badgeHtml = ""; + if (isGlobal) { + badgeHtml = 'Global'; + } + + const typeBadge = type + ? `${escapeHtml(humanizeName(type))}` + : ""; + + let buttonsHtml = ` + `; + + if ((isAdmin || (canManage && !isGlobal))) { + buttonsHtml += ` + + `; + } + + col.innerHTML = ` +
+
+
+ +
+
${escapeHtml(displayName)}${badgeHtml}
+
${typeBadge}
+

${escapeHtml(truncateDescription(description, 120))}

+
+ ${buttonsHtml} +
+
+
`; + + // Bind button events + const viewBtn = col.querySelector(".item-card-view-btn"); + const editBtn = col.querySelector(".item-card-edit-btn"); + const deleteBtn = col.querySelector(".item-card-delete-btn"); + + if (viewBtn && onView) viewBtn.addEventListener("click", (e) => { e.stopPropagation(); onView(plugin); }); + if (editBtn && onEdit) editBtn.addEventListener("click", (e) => { e.stopPropagation(); onEdit(plugin); }); + if (deleteBtn && onDelete) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); onDelete(plugin); }); + + // Clicking anywhere on the card opens the detail view + const cardEl = col.querySelector(".item-card"); + if (cardEl && onView) { + cardEl.style.cursor = "pointer"; + cardEl.addEventListener("click", () => onView(plugin)); + } + + return col; +} diff --git a/application/single_app/static/js/workspace/workspace_agents.js b/application/single_app/static/js/workspace/workspace_agents.js index a0839b25..623be234 100644 --- a/application/single_app/static/js/workspace/workspace_agents.js +++ b/application/single_app/static/js/workspace/workspace_agents.js @@ -4,14 +4,22 @@ import { showToast } from "../chat/chat-toast.js"; import * as agentsCommon from '../agents_common.js'; import { AgentModalStepper } from '../agent_modal_stepper.js'; +import { + humanizeName, truncateDescription, escapeHtml, + setupViewToggle, switchViewContainers, + openViewModal, createAgentCard +} from './view-utils.js'; // --- DOM Elements & Globals --- const agentsTbody = document.getElementById('agents-table-body'); const agentsErrorDiv = document.getElementById('workspace-agents-error'); const createAgentBtn = document.getElementById('create-agent-btn'); const agentsSearchInput = document.getElementById('agents-search'); +const agentsListView = document.getElementById('agents-list-view'); +const agentsGridView = document.getElementById('agents-grid-view'); let agents = []; let filteredAgents = []; +let currentViewMode = 'list'; // --- Function Definitions --- @@ -43,104 +51,87 @@ function filterAgents(searchTerm) { }); } renderAgentsTable(filteredAgents); + renderAgentsGrid(filteredAgents); } -// --- Helper Functions --- - -function truncateDisplayName(displayName, maxLength = 12) { - if (!displayName || displayName.length <= maxLength) { - return displayName; +// Open the view modal for an agent with Chat/Edit/Delete actions in the footer +function openAgentViewModal(agent) { + const callbacks = { + onChat: (a) => chatWithAgent(a.name), + onDelete: !agent.is_global ? (a) => { if (confirm(`Delete agent '${a.name}'?`)) deleteAgent(a.name); } : null + }; + if (!agent.is_global) { + callbacks.onEdit = (a) => openAgentModal(a); } - return displayName.substring(0, maxLength) + '...'; + openViewModal(agent, 'agent', callbacks); } +// --- Rendering Functions --- function renderAgentsTable(agentsList) { if (!agentsTbody) return; agentsTbody.innerHTML = ''; if (!agentsList.length) { const tr = document.createElement('tr'); - tr.innerHTML = 'No agents found.'; + tr.innerHTML = 'No agents found.'; agentsTbody.appendChild(tr); return; } - // Fetch selected_agent from user settings (async) - fetch('/api/user/settings').then(res => { - if (!res.ok) throw new Error('Failed to load user settings'); - return res.json(); - }).then(settings => { - let selectedAgentObj = settings.selected_agent; - if (!selectedAgentObj && settings.settings && settings.settings.selected_agent) { - selectedAgentObj = settings.settings.selected_agent; - } - let selectedAgentName = typeof selectedAgentObj === 'object' ? selectedAgentObj.name : selectedAgentObj; - agentsTbody.innerHTML = ''; - for (const agent of agentsList) { - const tr = document.createElement('tr'); - - // Create action buttons - let actionButtons = ``; - - if (!agent.is_global) { - actionButtons += ` - - - `; - } - - const truncatedDisplayName = truncateDisplayName(agent.display_name || agent.name || ''); - - tr.innerHTML = ` - - ${truncatedDisplayName} - ${agent.is_global ? ' Global' : ''} - - ${agent.description || 'No description available'} - ${actionButtons} - `; - agentsTbody.appendChild(tr); - } - }).catch(e => { - renderError('Could not load agent settings: ' + e.message); - // Fallback: render table without settings - agentsTbody.innerHTML = ''; - for (const agent of agentsList) { - const tr = document.createElement('tr'); - - // Create action buttons - let actionButtons = ` + `; - - if (!agent.is_global) { - actionButtons += ` - - - `; - } - - const truncatedDisplayName = truncateDisplayName(agent.display_name || agent.name || ''); - - tr.innerHTML = ` - - ${truncatedDisplayName} - ${agent.is_global ? ' Global' : ''} - - ${agent.description || 'No description available'} - ${actionButtons} - `; - agentsTbody.appendChild(tr); + + if (!isGlobal) { + actionButtons += ` + + `; } - }); + + tr.innerHTML = ` + + ${escapeHtml(displayName)} + ${isGlobal ? ' Global' : ''} + + ${escapeHtml(truncatedDesc)} + ${actionButtons} + `; + agentsTbody.appendChild(tr); + } +} + +function renderAgentsGrid(agentsList) { + if (!agentsGridView) return; + agentsGridView.innerHTML = ''; + if (!agentsList.length) { + agentsGridView.innerHTML = '
No agents found.
'; + return; + } + + for (const agent of agentsList) { + const card = createAgentCard(agent, { + onChat: (a) => chatWithAgent(a.name), + onView: (a) => openAgentViewModal(a), + onEdit: (a) => openAgentModal(a), + onDelete: (a) => { if (confirm(`Delete agent '${a.name}'?`)) deleteAgent(a.name); }, + canManage: !agent.is_global + }); + agentsGridView.appendChild(card); + } } async function fetchAgents() { @@ -151,6 +142,7 @@ async function fetchAgents() { agents = await res.json(); filteredAgents = agents; // Initialize filtered list renderAgentsTable(filteredAgents); + renderAgentsGrid(filteredAgents); } catch (e) { renderError(e.message); } @@ -177,17 +169,14 @@ function attachAgentTableEvents() { } agentsTbody.addEventListener('click', function (e) { - console.log('Agent table clicked, target:', e.target); - // Find the button element (could be the target or a parent) const editBtn = e.target.closest('.edit-agent-btn'); const deleteBtn = e.target.closest('.delete-agent-btn'); const chatBtn = e.target.closest('.chat-agent-btn'); + const viewBtn = e.target.closest('.view-agent-btn'); if (editBtn) { - console.log('Edit agent button clicked, dataset:', editBtn.dataset); const agent = agents.find(a => a.name === editBtn.dataset.name); - console.log('Found agent:', agent); openAgentModal(agent); } @@ -201,33 +190,27 @@ function attachAgentTableEvents() { const agentName = chatBtn.dataset.name; chatWithAgent(agentName); } + + if (viewBtn) { + const agent = agents.find(a => a.name === viewBtn.dataset.name); + if (agent) openAgentViewModal(agent); + } }); } async function chatWithAgent(agentName) { try { - console.log('DEBUG: chatWithAgent called with agentName:', agentName); - console.log('DEBUG: Available agents:', agents); - - // Find the agent to get its is_global status const agent = agents.find(a => a.name === agentName); - console.log('DEBUG: Found agent:', agent); - if (!agent) { throw new Error('Agent not found'); } - console.log('DEBUG: Agent is_global flag:', agent.is_global); - console.log('DEBUG: !!agent.is_global:', !!agent.is_global); - - // Set the selected agent with proper is_global flag const payloadData = { selected_agent: { name: agentName, is_global: !!agent.is_global } }; - console.log('DEBUG: Sending payload:', payloadData); const resp = await fetch('/api/user/settings/selected_agent', { method: 'POST', @@ -239,9 +222,6 @@ async function chatWithAgent(agentName) { throw new Error('Failed to select agent'); } - console.log('DEBUG: Agent selection saved successfully'); - - // Navigate to chat page window.location.href = '/chats'; } catch (err) { console.error('Error selecting agent for chat:', err); @@ -353,6 +333,17 @@ async function deleteAgent(name) { function initializeWorkspaceAgentUI() { window.agentModalStepper = new AgentModalStepper(false); attachAgentTableEvents(); + + // Set up view toggle + setupViewToggle('agents', 'agentsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, agentsListView, agentsGridView); + // Re-render grid if switching to grid and we have data + if (mode === 'grid' && filteredAgents.length) { + renderAgentsGrid(filteredAgents); + } + }); + fetchAgents(); } diff --git a/application/single_app/static/js/workspace/workspace_plugins.js b/application/single_app/static/js/workspace/workspace_plugins.js index 30fef0d5..84f1eb46 100644 --- a/application/single_app/static/js/workspace/workspace_plugins.js +++ b/application/single_app/static/js/workspace/workspace_plugins.js @@ -1,10 +1,14 @@ // workspace_plugins.js (refactored to use plugin_common.js and new multi-step modal) -import { renderPluginsTable, ensurePluginsTableInRoot, validatePluginManifest } from '../plugin_common.js'; +import { renderPluginsTable, renderPluginsGrid, ensurePluginsTableInRoot, validatePluginManifest } from '../plugin_common.js'; import { showToast } from "../chat/chat-toast.js" +import { + setupViewToggle, switchViewContainers, openViewModal +} from './view-utils.js'; const root = document.getElementById('workspace-plugins-root'); let plugins = []; let filteredPlugins = []; +let currentViewMode = 'list'; function renderLoading() { root.innerHTML = `
Loading...
`; @@ -14,6 +18,22 @@ function renderError(msg) { root.innerHTML = `
${msg}
`; } +function getViewHandlers() { + return { + onEdit: name => openPluginModal(plugins.find(p => p.name === name)), + onDelete: name => deletePlugin(name), + onView: name => { + const plugin = plugins.find(p => p.name === name); + if (plugin) { + openViewModal(plugin, 'action', { + onEdit: (item) => openPluginModal(item), + onDelete: (item) => deletePlugin(item.name) + }); + } + } + }; +} + function filterPlugins(searchTerm) { if (!searchTerm || !searchTerm.trim()) { filteredPlugins = plugins; @@ -26,14 +46,18 @@ function filterPlugins(searchTerm) { }); } - // Ensure table template is in place ensurePluginsTableInRoot(); + const handlers = getViewHandlers(); renderPluginsTable({ plugins: filteredPlugins, tbodySelector: '#plugins-table-body', - onEdit: name => openPluginModal(plugins.find(p => p.name === name)), - onDelete: name => deletePlugin(name) + ...handlers + }); + renderPluginsGrid({ + plugins: filteredPlugins, + containerSelector: '#plugins-grid-view', + ...handlers }); } @@ -47,12 +71,26 @@ async function fetchPlugins() { // Ensure table template is in place ensurePluginsTableInRoot(); + const handlers = getViewHandlers(); renderPluginsTable({ plugins: filteredPlugins, tbodySelector: '#plugins-table-body', - onEdit: name => openPluginModal(plugins.find(p => p.name === name)), - onDelete: name => deletePlugin(name) + ...handlers + }); + renderPluginsGrid({ + plugins: filteredPlugins, + containerSelector: '#plugins-grid-view', + ...handlers + }); + + // Set up view toggle (only once after template is in DOM) + setupViewToggle('plugins', 'pluginsViewPreference', (mode) => { + currentViewMode = mode; + switchViewContainers(mode, + document.getElementById('plugins-list-view'), + document.getElementById('plugins-grid-view') + ); }); // Set up the create action button diff --git a/application/single_app/templates/_agent_examples_modal.html b/application/single_app/templates/_agent_examples_modal.html index 52f95cdc..398e930c 100644 --- a/application/single_app/templates/_agent_examples_modal.html +++ b/application/single_app/templates/_agent_examples_modal.html @@ -92,7 +92,7 @@
-

+          
@@ -427,7 +427,12 @@
You do not have permission to manage group agents.
-
+
+
+ + + + +
- - - - - - - - - - - - - -
Display NameDescriptionActions
-
- Loading... -
- Select a group to load agents. -
+
+ + + + + + + + + + + + + +
Display NameDescriptionActions
+
+ Loading... +
+ Select a group to load agents. +
+
+
@@ -813,33 +822,42 @@

Group Workspace

-
+
+
+ + + + +
- - - - - - - - - - - - - -
Display NameDescriptionActions
-
- Loading... -
- Select a group to load actions. -
+
+ + + + + + + + + + + + + +
Display NameDescriptionActions
+
+ Loading... +
+ Select a group to load actions. +
+
+
@@ -851,6 +869,22 @@

Group Workspace

+ + + +
+ + + + +
- - - - - - - -
Display NameDescriptionActions
-
Loading...
- Loading agents... -
+ +
+ + + + + + + +
Display NameDescriptionActions
+
Loading...
+ Loading agents... +
+
+ +
@@ -730,16 +741,27 @@

Personal Workspace

+
+ + + + +
- - - - - -
Display NameDescriptionActions
+ +
+ + + + + +
Display NameDescriptionActions
+
+ +
@@ -754,6 +776,24 @@

Personal Workspace

+ + +