* @author Maximum Software Inc * @license GPL-3.0-only * @link https://agenistrator.com/ * * SPDX-FileCopyrightText: 2025-2026 Alex Ruban * SPDX-FileCopyrightText: 2025-2026 Maximum Software Inc * SPDX-License-Identifier: GPL-3.0-only */ namespace Agenistrator; // Direct access prevention if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Settings class. */ class Settings extends Manager { /** * Initialize the class and set its properties. * * @since 1.0.0 */ public function __construct() { // Call parent constructor with option suffix parent::__construct( 'settings' ); } /** * Register all hooks related to settings functionality * @since 1.0.0 */ public function register_hooks() { // Register settings hooks add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) ); // Register hook to save settings add_action( 'admin_post_agenistrator_save_settings', array( $this, 'save_settings' ) ); // Register settings AJAX handlers $settings_ajax_handlers = array( 'agenistrator_test_provider_config' => 'ajax_test_provider_configuration', 'agenistrator_get_provider_parameters' => 'ajax_get_provider_parameters', 'agenistrator_get_provider_data' => 'ajax_get_provider_data', 'agenistrator_save_provider' => 'ajax_save_provider', 'agenistrator_enable_provider' => 'ajax_enable_provider', 'agenistrator_disable_provider' => 'ajax_disable_provider', 'agenistrator_delete_provider' => 'ajax_delete_provider', 'agenistrator_delete_all_embeddings' => 'ajax_delete_all_embeddings', 'agenistrator_get_models' => 'ajax_get_models', 'agenistrator_sync_embeddings' => 'ajax_sync_embeddings', 'agenistrator_cancel_operation' => 'ajax_cancel_operation', 'agenistrator_update_tool_status' => 'ajax_update_tool_status', 'agenistrator_get_tools' => 'ajax_get_tools', 'agenistrator_enable_all_tools' => 'ajax_enable_all_tools', ); foreach ( $settings_ajax_handlers as $action => $callback ) { add_action( 'wp_ajax_' . $action, array( $this, $callback ) ); } } /** * Get default configuration * * Returns default settings values. * * @return array Default configuration * @since 1.0.0 */ protected function get_default_config() { // Return default configuration return array( 'chat_position' => 'bottom-right', ); } /** * Load HTML templates for the settings script * * @return array Associative array of template name => template content * @since 1.0.0 */ public function load_js_script_templates() { $templates = array(); // Define templates for the settings script $script_templates = array( 'settings-provider-parameter-row.html', 'settings-provider-list-item.html', 'settings-provider-form.html', 'settings-provider-form-container.html', 'settings-loading-row.html', 'settings-empty-row.html', 'settings-spinner-button.html', 'settings-tool-action-button.html', 'settings-message.html', 'settings-toggle-link.html', 'external-link.html', 'settings-select-option.html', 'settings-select-optgroup.html', 'tool-list-item.html', ); // Load each template $templates_dir = plugin_dir_path( __DIR__ ) . 'admin/html/'; foreach ( $script_templates as $template_file ) { $template_path = $templates_dir . $template_file; if ( file_exists( $template_path ) ) { // Get the template name without the .html extension for use as the key $template_name = basename( $template_file, '.html' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents $templates[ $template_name ] = file_get_contents( $template_path ); } } return $templates; } /** * Get localized strings for the settings script * * @return array Associative array of string keys => localized values * @since 1.0.0 */ public function get_js_strings() { // Define settings-specific strings only (common strings come from Admin class) return array( // Settings-specific common strings 'server_error' => __( 'Server error: {error_details}', 'agenistrator' ), 'chat_none' => __( 'None', 'agenistrator' ), 'embeddings_none' => __( 'None (Disable Semantic Search)', 'agenistrator' ), // Dynamic notice rendered in JS via the shared notice template when no embeddings model is selected. 'no_embeddings_warning_label' => __( 'No embeddings model configured!', 'agenistrator' ), 'no_embeddings_warning_message' => __( 'You need to configure an LLM provider and select an embeddings model before you can generate embeddings. Please go to the Provider Settings tab to add a provider, then select an embeddings model in the Model Settings tab.', 'agenistrator' ), // Provider settings 'provider_name_required' => __( 'Provider name is required', 'agenistrator' ), 'loading_parameters' => __( 'Loading parameters...', 'agenistrator' ), 'no_parameters_required' => __( 'No parameters required for this provider.', 'agenistrator' ), 'add_new_provider' => __( 'Add New Provider', 'agenistrator' ), 'error_loading_parameters' => __( 'Error loading provider parameters', 'agenistrator' ), 'error_loading_parameters_with_details' => __( 'Error loading provider parameters: {error_details}', 'agenistrator' ), 'testing_configuration' => __( 'Testing...', 'agenistrator' ), 'configuration_valid' => __( 'Configuration is valid!', 'agenistrator' ), 'configuration_invalid' => __( 'Invalid configuration', 'agenistrator' ), 'error_testing_configuration_with_details' => __( 'Error testing provider configuration: {error_details}', 'agenistrator' ), 'confirm_delete_provider' => __( 'Are you sure you want to delete this provider?', 'agenistrator' ), 'error_saving_provider' => __( 'Error saving provider', 'agenistrator' ), 'error_saving_provider_with_details' => __( 'Error saving provider: {error_details}', 'agenistrator' ), 'error_enabling_provider' => __( 'Error enabling provider', 'agenistrator' ), 'error_enabling_provider_with_details' => __( 'Error enabling provider: {error_details}', 'agenistrator' ), 'error_disabling_provider' => __( 'Error disabling provider', 'agenistrator' ), 'error_disabling_provider_with_details' => __( 'Error disabling provider: {error_details}', 'agenistrator' ), 'error_deleting_provider' => __( 'Error deleting provider', 'agenistrator' ), 'error_deleting_provider_with_details' => __( 'Error deleting provider: {error_details}', 'agenistrator' ), 'error_loading_provider_data_with_details' => __( 'Error loading provider data: {error_details}', 'agenistrator' ), 'edit_provider' => __( 'Edit Provider', 'agenistrator' ), 'no_providers_configured' => __( 'No providers configured. Click "Add New Provider" to get started.', 'agenistrator' ), 'enabled' => __( 'Enabled', 'agenistrator' ), 'disabled' => __( 'Disabled', 'agenistrator' ), 'enable' => __( 'Enable', 'agenistrator' ), 'disable' => __( 'Disable', 'agenistrator' ), 'edit' => __( 'Edit', 'agenistrator' ), 'delete' => __( 'Delete', 'agenistrator' ), 'select_provider_type' => __( 'Select Provider Type', 'agenistrator' ), // Provider form strings 'form_title' => __( 'Add New Provider', 'agenistrator' ), 'provider_name_label' => __( 'Provider Name', 'agenistrator' ), 'provider_name_description' => __( 'A friendly name for this provider configuration.', 'agenistrator' ), 'provider_type_label' => __( 'Provider Type', 'agenistrator' ), 'provider_type_description' => __( 'The type of LLM provider.', 'agenistrator' ), 'test_configuration_button' => __( 'Test Configuration', 'agenistrator' ), 'save_provider_button' => __( 'Save Provider', 'agenistrator' ), 'cancel_button' => __( 'Cancel', 'agenistrator' ), 'please_wait' => __( 'Please wait...', 'agenistrator' ), 'get_api_key' => __( 'Get API Key', 'agenistrator' ), // Settings-specific messages 'generating_embeddings' => __( 'Generating embeddings...', 'agenistrator' ), 'embeddings_scheduled' => __( 'Embedding generation has been scheduled and will run in the background.', 'agenistrator' ), 'error_generating_embeddings' => __( 'Error generating embeddings', 'agenistrator' ), // JavaScript error messages 'error_prefix' => __( 'Error: {error_details}', 'agenistrator' ), 'failed_to_load_tools' => __( 'Failed to load tools: {error_details}', 'agenistrator' ), 'failed_to_load_tools_generic' => __( 'Failed to load tools. Please try again.', 'agenistrator' ), 'failed_to_enable_tool' => __( 'Failed to enable tool: {error_details}', 'agenistrator' ), 'failed_to_disable_tool' => __( 'Failed to disable tool: {error_details}', 'agenistrator' ), 'failed_to_enable_tool_generic' => __( 'Failed to enable tool. Please try again.', 'agenistrator' ), 'failed_to_disable_tool_generic' => __( 'Failed to disable tool. Please try again.', 'agenistrator' ), 'enable_all_tools' => __( 'Enable All', 'agenistrator' ), 'confirm_enable_all_tools' => __( 'Are you sure you want to enable all tools?', 'agenistrator' ), 'enabling_all_tools' => __( 'Enabling...', 'agenistrator' ), 'failed_to_enable_all_tools' => __( 'Failed to enable all tools: {error_details}', 'agenistrator' ), 'failed_to_enable_all_tools_generic' => __( 'Failed to enable all tools. Please try again.', 'agenistrator' ), // Tool side effects labels 'tool_read_only' => __( 'Read-Only', 'agenistrator' ), 'tool_read_write' => __( 'Read-Write', 'agenistrator' ), 'always_enabled' => __( 'Always Enabled', 'agenistrator' ), // Console/debug messages 'error_parsing_provider_data' => __( 'Error parsing provider data', 'agenistrator' ), 'provider_list_body_not_found' => __( 'Provider list body element not found', 'agenistrator' ), 'error_fetching_models' => __( 'Error fetching models: {error_details}', 'agenistrator' ), 'server_error_fetching_models' => __( 'Server error fetching models', 'agenistrator' ), // Validation messages 'field_required' => __( '{field_name} is required', 'agenistrator' ), ); } /** * Register the CSS and JavaScript for the settings area. * * @since 1.0.0 */ public function enqueue_scripts() { // Only enqueue scripts for the settings page // We can't use admin_enqueue_scripts hook's $hook parameter because of https://core.trac.wordpress.org/ticket/18857 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only checking page parameter for script loading, not processing data if ( ! isset( $_GET['page'] ) || 'agenistrator-settings' !== $_GET['page'] ) { return; } // Register settings CSS with common CSS as dependency wp_register_style( 'agenistrator-settings', plugin_dir_url( __FILE__ ) . 'css/settings.css', array( 'agenistrator-common' ), AGENISTRATOR_VERSION ); wp_enqueue_style( 'agenistrator-settings' ); // Register settings script wp_register_script( 'agenistrator-settings', plugin_dir_url( __FILE__ ) . 'js/settings.js', array( 'jquery', 'agenistrator-common' ), AGENISTRATOR_VERSION, true ); // Get localized strings for the settings script $strings = $this->get_js_strings(); // Get templates for the settings script $templates = $this->load_js_script_templates(); // Prepare data for the script $script_data = array( 'strings' => $strings, 'templates' => $templates, 'is_embeddings_supported' => Embeddings_Manager::get_instance()->is_database_compatible(), ); // Localize and enqueue the script wp_localize_script( 'agenistrator-settings', 'agenistrator_settings', $script_data ); wp_enqueue_script( 'agenistrator-settings' ); // Enqueue progress bar script wp_enqueue_script( 'agenistrator-progress' ); } /** * Register the settings page. * * @since 1.0.0 */ public function add_settings_page() { add_submenu_page( 'agenistrator', __( 'Settings', 'agenistrator' ), __( 'Settings', 'agenistrator' ), 'manage_options', 'agenistrator-settings', array( $this, 'display_settings_page' ) ); } /** * Display settings page * @since 1.0.0 */ public function display_settings_page() { // Get current settings $chat_model = LLM_Manager::get_instance()->get_chat_model_id(); $embeddings_model = LLM_Manager::get_instance()->get_embeddings_model_id(); $embedding_count = Embeddings_Manager::get_instance()->get_embeddings_count( 'content' ); $chat_position = $this->get_config( 'chat_position', 'bottom-right' ); $default_context_window = $this->get_config( 'default_context_window', Model_Info::DEFAULT_CONTEXT_WINDOW ); $default_max_output_tokens = $this->get_config( 'default_max_output_tokens', Model_Info::DEFAULT_MAX_OUTPUT ); // Render embeddings progress bars (sync + cleanup) $embeddings_progress_bars = Admin::get_instance()->render_progress_bars( 'embeddings' ) . Admin::get_instance()->render_progress_bars( 'embeddings_cleanup' ); // Render the "embeddings not supported" warning only when the database is incompatible $embeddings_not_supported_warning = ''; if ( ! Embeddings_Manager::get_instance()->is_database_compatible() ) { $embeddings_not_supported_warning = String_Utils::render_error_notice( '', array( 'classes' => 'inline', 'label' => esc_html__( 'Embeddings not supported', 'agenistrator' ), 'message' => esc_html__( 'Embeddings require MySQL 5.7.8+ or MariaDB 10.2.7+. Your database does not support the vector operations needed for semantic search.', 'agenistrator' ), ) ); } // Prepare provider data for JSON encoding $provider_types = Provider_Manager::get_instance()->get_provider_type_display_names(); // Convert provider instances to associative arrays for JSON encoding $provider_instances = array(); foreach ( Provider_Manager::get_instance()->get_all_providers() as $id => $provider ) { $provider_instances[ $id ] = array( 'display_name' => $provider->get_display_name(), 'type' => $provider->get_type_id(), 'enabled' => $provider->is_enabled(), 'parameters' => $provider->get_params(), ); } // Combine provider data into a single JSON object $provider_data = array( 'instances' => $provider_instances, 'types' => $provider_types, ); // Generate free trial recommendation if no providers configured $free_trial_recommendation = ''; if ( empty( $provider_instances ) ) { $free_trial_recommendation = String_Utils::render( 'settings-free-trial-recommendation', array( 'recommendation_title' => esc_html__( '🚀 Try Agenistrator for Free!', 'agenistrator' ), 'recommendation_intro' => esc_html__( 'Get started with Agenistrator without any cost using one of these free options:', 'agenistrator' ), 'option_1_title' => esc_html__( 'Option 1: Ollama Cloud (recommended)', 'agenistrator' ), 'option_1_step_1' => String_Utils::replace_tags( esc_html__( 'Sign up for a free account at {a-href}ollama.com{/a}', 'agenistrator' ), array( 'a-href' => '', '/a' => '', ) ), 'option_1_step_2' => esc_html__( 'Create an API key in your Ollama dashboard', 'agenistrator' ), 'option_1_step_3' => esc_html__( 'Click "Add New Provider" below, select "Ollama Cloud" as the type, and enter your API key', 'agenistrator' ), 'option_2_title' => esc_html__( 'Option 2: OpenRouter Free Models', 'agenistrator' ), 'option_2_step_1' => String_Utils::replace_tags( esc_html__( 'Sign up for a free account at {a-href}openrouter.ai{/a}', 'agenistrator' ), array( 'a-href' => '', '/a' => '', ) ), 'option_2_step_2' => esc_html__( 'Create an API key in your OpenRouter dashboard', 'agenistrator' ), 'option_2_step_3' => esc_html__( 'Click "Add New Provider" below, select "OpenRouter" as the type, and enter your API key', 'agenistrator' ), 'option_2_step_4' => esc_html__( 'Go to Model Settings and select any free model as your Chat Model', 'agenistrator' ), ) ); } // Prepare attributes for the template $attributes = array( 'page_title' => esc_html( get_admin_page_title() ), 'chat_model' => esc_attr( $chat_model ), 'embeddings_model' => esc_attr( $embeddings_model ), 'embedding_count' => esc_html( $embedding_count ), 'provider_data_json' => json_encode( $provider_data, JSON_HEX_TAG ), // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Using json_encode() directly to avoid wp_json_encode_options filter interference from other plugins. JSON_HEX_TAG encodes < and > to prevent injection inside the script tag. 'free_trial_recommendation' => $free_trial_recommendation, 'nonce_field' => wp_nonce_field( 'agenistrator_settings', 'agenistrator_nonce', true, false ), 'admin_post_url' => admin_url( 'admin-post.php' ), 'submit_button_model_settings' => get_submit_button( '', 'primary', 'submit', true, array( 'id' => 'submit-model-settings' ) ), 'submit_button_ui_settings' => get_submit_button( '', 'primary', 'submit', true, array( 'id' => 'submit-ui-settings' ) ), 'embeddings_progress_bars' => $embeddings_progress_bars, 'embeddings_not_supported_warning' => $embeddings_not_supported_warning, // Tab names 'provider_settings_tab' => esc_html__( 'Provider Settings', 'agenistrator' ), 'model_settings_tab' => esc_html__( 'Model Settings', 'agenistrator' ), 'tools_tab' => esc_html__( 'Tools', 'agenistrator' ), 'embeddings_tab' => esc_html__( 'Embeddings', 'agenistrator' ), 'ui_settings_tab' => esc_html__( 'UI Settings', 'agenistrator' ), // Provider settings section 'provider_settings_heading' => esc_html__( 'Provider Settings', 'agenistrator' ), 'provider_settings_description' => esc_html__( 'Configure your LLM providers. You need at least one properly configured provider to use the assistant.', 'agenistrator' ), 'provider_list_heading' => esc_html__( 'Provider List', 'agenistrator' ), 'provider_name_column' => esc_html__( 'Name', 'agenistrator' ), 'provider_type_column' => esc_html__( 'Type', 'agenistrator' ), 'provider_status_column' => esc_html__( 'Status', 'agenistrator' ), 'provider_actions_column' => esc_html__( 'Actions', 'agenistrator' ), 'add_provider_button' => esc_html__( 'Add New Provider', 'agenistrator' ), // Model settings section 'model_settings_heading' => esc_html__( 'Model Settings', 'agenistrator' ), 'model_settings_description' => esc_html__( 'Configure which models to use for different functions.', 'agenistrator' ), 'model_quality_notice_message' => esc_html__( 'The performance of your agent is highly dependent on the quality of the LLM model used. If you are not seeing the desired results, try switching to a different model.', 'agenistrator' ), 'no_providers_warning_title' => esc_html__( 'No configured providers found!', 'agenistrator' ), 'no_providers_warning_message' => esc_html__( 'You need to configure at least one LLM provider before you can select models. Please go to the Provider Settings tab and add a provider configuration.', 'agenistrator' ), 'chat_model_label' => esc_html__( 'Chat Model', 'agenistrator' ), 'chat_model_description' => esc_html__( 'The model to use for generating chat responses. Available models will be loaded after provider is added.', 'agenistrator' ), 'chat_model_recommendation' => esc_html__( 'Recommended: state of the art model for best results. For free usage, try Ollama Cloud (free tier) or "Free Models Router" via OpenRouter.', 'agenistrator' ), 'embeddings_model_label' => esc_html__( 'Embeddings Model', 'agenistrator' ), 'embeddings_cost_warning' => esc_html__( 'Selecting an embeddings model will trigger background synchronization of embeddings for all website content. This may incur significant API costs if your site has a lot of content.', 'agenistrator' ), 'embeddings_model_description' => esc_html__( 'The model to use for generating content embeddings and power semantic search. Available models will be loaded after provider is added.', 'agenistrator' ), 'embeddings_model_recommendation' => esc_html__( 'Recommended: OpenAI text-embedding-3-small for OpenAI users, or BAAI: bge-m3 via OpenRouter.', 'agenistrator' ), 'context_window_fallbacks_heading' => esc_html__( 'Context Window Fallbacks', 'agenistrator' ), 'context_window_fallbacks_description' => esc_html__( 'These values are used when model information cannot be retrieved from the models.dev API.', 'agenistrator' ), 'default_context_window' => esc_attr( $default_context_window ), 'default_context_window_label' => esc_html__( 'Default Context Window', 'agenistrator' ), 'default_context_window_description' => esc_html__( 'The default context window size in tokens when model info is unavailable.', 'agenistrator' ), 'default_max_output_tokens' => esc_attr( $default_max_output_tokens ), 'default_max_output_tokens_label' => esc_html__( 'Default Max Output Tokens', 'agenistrator' ), 'default_max_output_tokens_description' => esc_html__( 'The default maximum output tokens reserved for model responses.', 'agenistrator' ), // Tools section 'tools_heading' => esc_html__( 'Tool Settings', 'agenistrator' ), 'tools_description' => esc_html__( 'Control which tools the model can use.', 'agenistrator' ), 'tools_warning_title' => esc_html__( 'Warning: Use with caution', 'agenistrator' ), 'tools_warning_message' => esc_html__( 'By default, only read-only tools are enabled. Enabling tools with side effects can lead to severe negative consequences, including loss of data, reputational damage, unintended changes to your website, and other unforeseen negative outcomes. Review each tool carefully before enabling. The developers of Agenistrator accept no responsibility for actions taken by any model, whether due to prompt injection attacks, model hallucinations, or any other reasons.', 'agenistrator' ), 'available_tools_heading' => esc_html__( 'Available Tools', 'agenistrator' ), 'enable_all_tools_button' => esc_html__( 'Enable All', 'agenistrator' ), 'tool_name_column' => esc_html__( 'Tool Name', 'agenistrator' ), 'tool_description_column' => esc_html__( 'Description', 'agenistrator' ), 'tool_side_effects_column' => esc_html__( 'Side Effects', 'agenistrator' ), 'tool_status_column' => esc_html__( 'Status', 'agenistrator' ), 'tool_actions_column' => esc_html__( 'Actions', 'agenistrator' ), 'no_tools_message' => esc_html__( 'No tools available.', 'agenistrator' ), // Embeddings section 'embeddings_heading' => esc_html__( 'Content Embeddings', 'agenistrator' ), 'embeddings_description' => esc_html__( 'Manage the embeddings that powers semantic search of your WordPress content.', 'agenistrator' ), 'embedding_statistics_heading' => esc_html__( 'Embedding Statistics', 'agenistrator' ), 'total_embedded_items_label' => esc_html__( 'Total Embedded Items:', 'agenistrator' ), 'embeddings_auto_generation_description' => esc_html__( 'Embeddings are automatically generated for new content.', 'agenistrator' ), 'sync_embeddings_heading' => esc_html__( 'Sync Embeddings', 'agenistrator' ), 'sync_embeddings_cost_warning' => esc_html__( 'Clicking "Sync Embeddings" button will trigger background sync of embeddings for website content. This may incur significant API costs if your site has a lot of content.', 'agenistrator' ), 'sync_embeddings_button' => esc_html__( 'Sync Embeddings', 'agenistrator' ), 'regenerate_all_embeddings_label' => esc_html__( 'Regenerate all embeddings (takes longer)', 'agenistrator' ), 'sync_embeddings_description' => esc_html__( 'Sync embeddings for modified content, or regenerate all embeddings if checked.', 'agenistrator' ), // UI Settings section 'ui_settings_heading' => esc_html__( 'UI Settings', 'agenistrator' ), 'ui_settings_description' => esc_html__( 'Configure how the chat interface appears in the WordPress admin.', 'agenistrator' ), 'chat_position_label' => esc_html__( 'Chat Position', 'agenistrator' ), 'chat_position_description' => esc_html__( 'Where the chat interface should appear in the admin area.', 'agenistrator' ), 'bottom_right_option' => esc_html__( 'Bottom Right', 'agenistrator' ), 'bottom_left_option' => esc_html__( 'Bottom Left', 'agenistrator' ), 'top_right_option' => esc_html__( 'Top Right', 'agenistrator' ), 'top_left_option' => esc_html__( 'Top Left', 'agenistrator' ), ); // Chat position select options $attributes['chat_position_bottom_right'] = 'bottom-right' === $chat_position ? 'selected' : ''; $attributes['chat_position_bottom_left'] = 'bottom-left' === $chat_position ? 'selected' : ''; $attributes['chat_position_top_right'] = 'top-right' === $chat_position ? 'selected' : ''; $attributes['chat_position_top_left'] = 'top-left' === $chat_position ? 'selected' : ''; // Render the template // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Template system outputs pre-escaped HTML; all $attributes values are escaped above echo String_Utils::render( 'settings', $attributes ); } /** * Saves all settings. * @since 1.0.0 */ public function save_settings() { try { // Verify nonce if ( check_admin_referer( 'agenistrator_settings', 'agenistrator_nonce' ) === false ) { throw new \Exception( __( 'Security check failed. Please try again.', 'agenistrator' ) ); } // Check if user has permission if ( ! current_user_can( 'manage_options' ) ) { throw new \Exception( __( 'You do not have sufficient permissions to access this page.', 'agenistrator' ) ); } // Model Settings if ( isset( $_POST['agenistrator_chat_model'] ) ) { LLM_Manager::get_instance()->set_chat_model_id( sanitize_text_field( wp_unslash( $_POST['agenistrator_chat_model'] ) ) ); } if ( isset( $_POST['agenistrator_embeddings_model'] ) ) { $embeddings_model = sanitize_text_field( wp_unslash( $_POST['agenistrator_embeddings_model'] ) ); $old_embeddings_model = LLM_Manager::get_instance()->get_embeddings_model_id(); // If switching to "None" (empty), disable embeddings and drop vector table if ( empty( $embeddings_model ) && ! empty( $old_embeddings_model ) ) { Embeddings_Manager::get_instance()->disable_embeddings(); } LLM_Manager::get_instance()->set_embeddings_model_id( $embeddings_model ); } // Context window fallback settings if ( isset( $_POST['agenistrator_default_context_window'] ) ) { $value = absint( wp_unslash( $_POST['agenistrator_default_context_window'] ) ); if ( $value >= 1000 ) { $this->set_config( 'default_context_window', $value ); } } if ( isset( $_POST['agenistrator_default_max_output_tokens'] ) ) { $value = absint( wp_unslash( $_POST['agenistrator_default_max_output_tokens'] ) ); if ( $value >= 100 ) { $this->set_config( 'default_max_output_tokens', $value ); } } // UI Settings if ( isset( $_POST['agenistrator_chat_position'] ) ) { $this->set_config( 'chat_position', sanitize_key( wp_unslash( $_POST['agenistrator_chat_position'] ) ) ); } // Save all settings at once $this->save_config(); // Build redirect URL $redirect_url = add_query_arg( array( 'page' => 'agenistrator-settings', ), admin_url( 'admin.php' ) ); // Preserve the active tab if provided and valid $tabs = array( '#provider-settings', '#model-settings', '#tools', '#embeddings', '#ui-settings' ); if ( ! empty( $_POST['agenistrator_active_tab'] ) ) { $active_tab = sanitize_text_field( wp_unslash( $_POST['agenistrator_active_tab'] ) ); if ( in_array( $active_tab, $tabs, true ) ) { $redirect_url .= $active_tab; } } wp_safe_redirect( $redirect_url ); exit; } catch ( \Throwable $e ) { wp_die( esc_html( $e->getMessage() ) ); } } /** * AJAX handler for saving provider * @since 1.0.0 */ public function ajax_save_provider() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } $provider_type = isset( $_POST['provider_type'] ) ? sanitize_key( wp_unslash( $_POST['provider_type'] ) ) : ''; $display_name = isset( $_POST['display_name'] ) ? sanitize_text_field( wp_unslash( $_POST['display_name'] ) ) : ''; $provider_id = isset( $_POST['provider_id'] ) ? sanitize_key( wp_unslash( $_POST['provider_id'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Intentional: Sanitization corrupts legitimate user input, escape on output. Keys sanitized via sanitize_key(). String leaves UTF-8 validated. $params = isset( $_POST['params'] ) ? $this->sanitize_provider_params( wp_unslash( $_POST['params'] ) ) : array(); if ( empty( $provider_type ) ) { throw new Ajax_Exception( __( 'Provider type is required', 'agenistrator' ), 400 ); } if ( empty( $display_name ) ) { throw new Ajax_Exception( __( 'Provider name is required', 'agenistrator' ), 400 ); } $is_new_provider = empty( $provider_id ); if ( $is_new_provider ) { // Create new provider $provider_id = Provider_Manager::get_instance()->create_provider( $provider_type, $display_name, $params ); // Enable the provider by default Provider_Manager::get_instance()->enable_provider( $provider_id ); // Initialize default model settings for the new provider $provider = Provider_Manager::get_instance()->get_provider( $provider_id ); LLM_Manager::get_instance()->initialize_default_models_for_provider( $provider ); } else { // Update existing provider Provider_Manager::get_instance()->update_provider( $provider_id, $display_name, $params ); } wp_send_json_success( array( 'provider_id' => $provider_id ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for testing provider configuration * @since 1.0.0 */ public function ajax_test_provider_configuration() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } $provider_type = isset( $_POST['provider_type'] ) ? sanitize_key( wp_unslash( $_POST['provider_type'] ) ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Intentional: Sanitization corrupts legitimate user input, escape on output. Keys sanitized via sanitize_key(). String leaves UTF-8 validated. $params = isset( $_POST['params'] ) ? $this->sanitize_provider_params( wp_unslash( $_POST['params'] ) ) : array(); if ( empty( $provider_type ) ) { throw new Ajax_Exception( __( 'Provider type is required', 'agenistrator' ), 400 ); } // Create a temporary provider instance for testing $provider = Provider_Manager::get_instance()->create_temp_provider( $provider_type, $params ); // Test the configuration (throws on failure) $provider->test_configuration(); wp_send_json_success( array( 'is_valid' => true ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for enabling provider * @since 1.0.0 */ public function ajax_enable_provider() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get provider ID $provider_id = isset( $_POST['provider_id'] ) ? sanitize_key( wp_unslash( $_POST['provider_id'] ) ) : null; if ( ! $provider_id ) { throw new Ajax_Exception( __( 'No provider ID received.', 'agenistrator' ), 400 ); } // Enable provider Provider_Manager::get_instance()->enable_provider( $provider_id ); // Initialize default model settings if none configured $provider = Provider_Manager::get_instance()->get_provider( $provider_id ); LLM_Manager::get_instance()->initialize_default_models_for_provider( $provider ); wp_send_json_success( array( 'message' => __( 'Provider enabled successfully.', 'agenistrator' ) ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * Reset orphaned model settings to defaults from remaining providers * * Should be called after disabling or deleting a provider. If the current * chat model has no provider, tries to set a default from remaining providers. * Embeddings model is only cleared (not auto-set) to avoid unexpected costs. */ private function clear_orphaned_model_settings() { $llm_manager = LLM_Manager::get_instance(); // Check if chat model is orphaned $chat_model = $llm_manager->get_chat_model_id(); if ( ! empty( $chat_model ) && $llm_manager->get_provider_for_model( $chat_model ) === null ) { // Clear first, then try to set a default from remaining providers $llm_manager->set_chat_model_id( '' ); foreach ( Provider_Manager::get_instance()->get_enabled_providers() as $provider ) { $default_chat = $provider->get_default_chat_model(); if ( null !== $default_chat ) { $namespaced = $llm_manager->namespace_model_id( $provider, $default_chat ); $llm_manager->set_chat_model_id( $namespaced ); break; } } } // Check if embeddings model is orphaned - only clear, don't auto-set to avoid unexpected costs $embeddings_model = $llm_manager->get_embeddings_model_id(); if ( ! empty( $embeddings_model ) && $llm_manager->get_provider_for_model( $embeddings_model ) === null ) { Embeddings_Manager::get_instance()->disable_embeddings(); $llm_manager->set_embeddings_model_id( '' ); } } /** * AJAX handler for disabling provider * @since 1.0.0 */ public function ajax_disable_provider() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get provider ID $provider_id = isset( $_POST['provider_id'] ) ? sanitize_key( wp_unslash( $_POST['provider_id'] ) ) : null; if ( ! $provider_id ) { throw new Ajax_Exception( __( 'No provider ID received.', 'agenistrator' ), 400 ); } // Disable provider Provider_Manager::get_instance()->disable_provider( $provider_id ); // Clear any model settings that no longer have a provider $this->clear_orphaned_model_settings(); wp_send_json_success( array( 'message' => __( 'Provider disabled successfully.', 'agenistrator' ) ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for deleting provider * @since 1.0.0 */ public function ajax_delete_provider() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get provider ID $provider_id = isset( $_POST['provider_id'] ) ? sanitize_key( wp_unslash( $_POST['provider_id'] ) ) : null; if ( ! $provider_id ) { throw new Ajax_Exception( __( 'No provider ID received.', 'agenistrator' ), 400 ); } // Delete provider Provider_Manager::get_instance()->delete_provider( $provider_id ); // Clear any model settings that no longer have a provider $this->clear_orphaned_model_settings(); wp_send_json_success( array( 'message' => __( 'Provider deleted successfully.', 'agenistrator' ) ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for getting provider parameters * @since 1.0.0 */ public function ajax_get_provider_parameters() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get provider type $provider_type = isset( $_REQUEST['provider_type'] ) ? sanitize_key( wp_unslash( $_REQUEST['provider_type'] ) ) : null; if ( ! $provider_type ) { throw new Ajax_Exception( __( 'No provider type received.', 'agenistrator' ), 400 ); } // Create a temporary provider to get parameter definitions $provider = Provider_Manager::get_instance()->create_temp_provider( $provider_type ); $parameters = $provider->get_parameter_definitions(); wp_send_json_success( array( 'parameters' => $parameters, 'api_key_url' => $provider->get_api_key_url(), 'provider_name' => $provider->get_type_id(), ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for getting provider data * @since 1.0.0 */ public function ajax_get_provider_data() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get provider ID $provider_id = isset( $_REQUEST['provider_id'] ) ? sanitize_key( wp_unslash( $_REQUEST['provider_id'] ) ) : null; if ( ! $provider_id ) { throw new Ajax_Exception( __( 'No provider ID received.', 'agenistrator' ), 400 ); } // Get provider data $provider = Provider_Manager::get_instance()->get_provider( $provider_id ); if ( ! $provider ) { throw new Ajax_Exception( __( 'Provider not found.', 'agenistrator' ), 404 ); } $provider_data = array( 'id' => $provider_id, 'display_name' => $provider->get_display_name(), 'type' => $provider->get_type_id(), 'enabled' => $provider->is_enabled(), 'parameters' => $provider->get_params(), ); wp_send_json_success( array( 'provider_data' => $provider_data ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for deleting all embeddings * @since 1.0.0 */ public function ajax_delete_all_embeddings() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Check if embeddings are supported if ( ! Embeddings_Manager::get_instance()->is_database_compatible() ) { throw new Ajax_Exception( __( 'Embeddings are not supported on your database.', 'agenistrator' ), 400 ); } // Delete all embeddings Embeddings_Manager::get_instance()->delete_all_embeddings(); wp_send_json_success( array( 'message' => __( 'All embeddings deleted successfully.', 'agenistrator' ) ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for syncing embeddings. * @since 1.0.0 */ public function ajax_sync_embeddings() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Check if embeddings are supported if ( ! Embeddings_Manager::get_instance()->is_database_compatible() ) { throw new Ajax_Exception( __( 'Embeddings are not supported on your database.', 'agenistrator' ), 400 ); } // Check if regenerate_all is set // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Boolean flag compared to literal, no sanitization needed. $regenerate_all = isset( $_POST['regenerate_all'] ) && 'true' === wp_unslash( $_POST['regenerate_all'] ); // Schedule content embeddings sync via cron // Manual sync: walk all posts (since_last_sync=false) and always run cleanup Embeddings_Manager::get_instance()->schedule_sync_content_embeddings( $regenerate_all, true, false ); // Send success response wp_send_json_success( array( 'message' => __( 'Embedding sync has been scheduled and will run in the background.', 'agenistrator' ), ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for cancelling an operation by instance ID. * @since 1.0.0 */ public function ajax_cancel_operation() { // Check nonce check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } $instance_id = isset( $_POST['instance_id'] ) ? sanitize_text_field( wp_unslash( $_POST['instance_id'] ) ) : ''; if ( empty( $instance_id ) ) { throw new Ajax_Exception( __( 'instance_id is required.', 'agenistrator' ), 400 ); } $tracker = Progress_Tracker::get_by_instance_id( $instance_id ); if ( null === $tracker ) { throw new Ajax_Exception( __( 'No running operation found with that instance ID.', 'agenistrator' ), 404 ); } $tracker->cancel(); wp_send_json_success( array( 'message' => __( 'Operation cancelled.', 'agenistrator' ) ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for getting available models * @since 1.0.0 */ public function ajax_get_models() { // Check nonce for security check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get models from all configured providers, organized by type $models_by_type = array( 'chat' => array(), 'embedding' => array(), ); $llm_manager = LLM_Manager::get_instance(); $providers = Provider_Manager::get_instance()->get_enabled_providers(); foreach ( $providers as $provider_id => $provider ) { try { $available_models = $provider->get_available_models_cached(); $provider_name = $provider->get_display_name(); // Add chat models with namespaced IDs if ( isset( $available_models['chat'] ) && is_array( $available_models['chat'] ) ) { foreach ( $available_models['chat'] as $model ) { if ( isset( $model['id'] ) && isset( $model['name'] ) ) { $models_by_type['chat'][] = array( 'id' => $llm_manager->namespace_model_id( $provider, $model['id'] ), 'name' => $model['name'], 'provider' => $provider_name, 'provider_id' => $provider_id, ); } } } // Add embedding models with namespaced IDs if ( isset( $available_models['embedding'] ) && is_array( $available_models['embedding'] ) ) { foreach ( $available_models['embedding'] as $model ) { if ( isset( $model['id'] ) && isset( $model['name'] ) ) { $models_by_type['embedding'][] = array( 'id' => $llm_manager->namespace_model_id( $provider, $model['id'] ), 'name' => $model['name'], 'provider' => $provider_name, 'provider_id' => $provider_id, ); } } } } catch ( \Throwable $e ) { // Skip providers with errors continue; } } wp_send_json_success( $models_by_type ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for getting all tools * @since 1.0.0 */ public function ajax_get_tools() { // Check nonce for security check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get all tools $tools = Tool_Manager::get_instance()->get_tools(); $tool_data = array(); // Format tool data for the UI foreach ( $tools as $id => $tool ) { $tool_data[] = array( 'id' => $id, 'name' => $tool->get_name(), 'description' => $tool->get_description(), 'is_read_only' => $tool->is_read_only(), 'enabled' => $tool->is_enabled(), 'is_always_enabled' => $tool->is_always_enabled(), 'is_always_imported' => $tool->is_always_imported(), 'parameters' => $tool->get_parameters(), ); } wp_send_json_success( array( 'tools' => $tool_data ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for updating a tool's status (enable/disable) * @since 1.0.0 */ public function ajax_update_tool_status() { // Check nonce for security check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } // Get tool ID $tool_id = isset( $_POST['tool_id'] ) ? sanitize_key( wp_unslash( $_POST['tool_id'] ) ) : null; if ( ! $tool_id ) { throw new Ajax_Exception( __( 'No tool ID received.', 'agenistrator' ), 400 ); } // Get status (enable or disable) $status = isset( $_POST['status'] ) ? sanitize_key( wp_unslash( $_POST['status'] ) ) : null; if ( ! in_array( $status, array( 'enable', 'disable' ), true ) ) { throw new Ajax_Exception( __( 'Invalid status. Must be either "enable" or "disable".', 'agenistrator' ), 400 ); } // Update tool status $enabled = ( 'enable' === $status ); $result = Tool_Manager::get_instance()->set_tool_enabled( $tool_id, $enabled ); if ( ! $result ) { throw new Ajax_Exception( __( 'Tool status was NOT successfully updated.', 'agenistrator' ) ); } $message = $enabled ? __( 'Tool enabled successfully.', 'agenistrator' ) : __( 'Tool disabled successfully.', 'agenistrator' ); wp_send_json_success( array( 'message' => $message ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * AJAX handler for enabling all tools at once * @since 1.0.0 */ public function ajax_enable_all_tools() { // Check nonce for security check_ajax_referer( 'agenistrator_nonce', 'nonce' ); try { // Check permissions if ( ! current_user_can( 'manage_options' ) ) { throw new Ajax_Exception( __( 'You do not have permission to perform this action.', 'agenistrator' ), 403 ); } $tool_manager = Tool_Manager::get_instance(); $tools = $tool_manager->get_tools(); $enabled_count = 0; foreach ( $tools as $id => $tool ) { // Skip tools that are always enabled if ( $tool->is_always_enabled() ) { continue; } // Enable the tool if ( $tool_manager->set_tool_enabled( $id, true ) ) { ++$enabled_count; } } wp_send_json_success( array( 'message' => String_Utils::replace_tags( __( '{count} tools enabled successfully.', 'agenistrator' ), array( 'count' => $enabled_count ) ), ) ); } catch ( \Throwable $e ) { WordPress_Utils::send_json_exception( $e ); } } /** * Sanitize provider parameters. * * Sanitizes array keys via sanitize_key() and validates UTF-8 of string * leaves via wp_check_invalid_utf8() with stripping enabled. String values * are otherwise preserved verbatim because destructive sanitization corrupts * legitimate user input; values are escaped on output instead. * * Caller is expected to have already applied wp_unslash() to the entire * array (which recurses into leaves), so this helper does not unslash again. * * @param mixed $params Raw provider parameters (already unslashed by caller). * @return array Parameters with sanitized keys and UTF-8-validated string leaves. */ private function sanitize_provider_params( $params ) { if ( ! is_array( $params ) ) { return array(); } $sanitized = array(); foreach ( $params as $key => $value ) { $sanitized_key = sanitize_key( $key ); if ( is_array( $value ) ) { $sanitized[ $sanitized_key ] = $this->sanitize_provider_params( $value ); } elseif ( is_numeric( $value ) ) { $sanitized[ $sanitized_key ] = $value; } elseif ( is_bool( $value ) ) { $sanitized[ $sanitized_key ] = $value; } else { $sanitized[ $sanitized_key ] = wp_check_invalid_utf8( (string) $value, true ); } } return $sanitized; } }