PluginProbe
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder / 51.1.83
King Addons for Elementor – 100+ Elementor Widgets, 4 000+ Elementor Templates, WooCommerce Builder, Mega Menu, Popup Builder v51.1.83
51.1.84 51.1.85 51.1.83 51.1.82 51.1.81 51.1.79 51.1.78 51.1.77 51.1.76 51.1.74 51.1.75 51.1.65 51.1.64 51.1.63 trunk 51.1.14 51.1.2 51.1.35 51.1.36 51.1.37 51.1.38 51.1.39 51.1.44 51.1.45 51.1.46 All 39 releases
← All changes | includes/Admin.php +792 -270 51.1.4451.1.83 View file →
@@ -24,8 +24,9 @@
24 24 add_action('admin_menu', [$this, 'addUpgradeMenu'], 9999999999); // Highest priority to add at the very end
25 25
26 26 // Reorder submenu items after ALL entries are registered.
27 27 add_action('admin_menu', [$this, 'reorderKingAddonsSubmenu'], 1000000000);
28 + add_action('admin_menu', [$this, 'reorderWooBuilderSubmenu'], 1000000000);
28 29
29 30 add_action('admin_init', [$this, 'createSettings']);
30 31 add_action('admin_init', [$this, 'createAiSettings']);
31 32
@@ -37,8 +38,11 @@
37 38 add_action('admin_init', [$this, 'createWishlistSettings']);
38 39 }
39 40 add_action('admin_enqueue_scripts', [$this, 'enqueueUpgradeLinkScript']);
40 41 add_action('admin_enqueue_scripts', [$this, 'enqueueGlobalAdminStyles']);
42 +
43 + // AJAX handler for Trinity Backup plugin installation
44 + add_action('wp_ajax_king_addons_install_trinity_backup', [$this, 'handleInstallTrinityBackup']);
41 45 }
42 46 }
43 47
44 48 function addAdminMenu(): void
@@ -87,31 +91,8 @@
87 91
88 92 // Get options for extension toggle checks (before any extension checks)
89 93 $options = get_option('king_addons_options', []);
90 94
91 - // Check Wishlist extension toggle
92 - $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
93 - && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
94 - if ($wishlist_enabled) {
95 - add_submenu_page(
96 - 'king-addons',
97 - esc_html__('Wishlist', 'king-addons'),
98 - esc_html__('Wishlist', 'king-addons'),
99 - 'manage_options',
100 - 'king-addons-wishlist',
101 - [$this, 'renderWishlistPage']
102 - );
103 -
104 - add_submenu_page(
105 - 'king-addons',
106 - esc_html__('Wishlist Analytics', 'king-addons'),
107 - esc_html__('Wishlist Analytics', 'king-addons'),
108 - 'manage_options',
109 - 'king-addons-wishlist-analytics',
110 - [$this, 'renderWishlistAnalyticsPage']
111 - );
112 - }
113 -
114 95 // Check Cookie / Consent Bar extension toggle
115 96 $cookie_consent_enabled = !isset($options['ext_cookie-consent']) || $options['ext_cookie-consent'] === 'enabled';
116 97 if ($cookie_consent_enabled && (defined('KING_ADDONS_EXT_COOKIE_CONSENT') ? KING_ADDONS_EXT_COOKIE_CONSENT : true) && class_exists('King_Addons\Cookie_Consent')) {
117 98 add_submenu_page(
@@ -213,14 +194,38 @@
213 194 if ($popup_builder_enabled && (defined('KING_ADDONS_EXT_POPUP_BUILDER') ? KING_ADDONS_EXT_POPUP_BUILDER : true) && class_exists('King_Addons\Popup_Builder')) {
214 195 self::showPopupBuilder();
215 196 }
216 197
217 - // Check WooCommerce Builder extension toggle
218 - $woo_builder_enabled = !isset($options['ext_woo-builder']) || $options['ext_woo-builder'] === 'enabled';
219 - if ($woo_builder_enabled && (defined('KING_ADDONS_EXT_WOO_BUILDER') ? KING_ADDONS_EXT_WOO_BUILDER : true)) {
198 + // WooCommerce Builder is a top-level menu. Store screens hang off it
199 + // when it exists; otherwise they stay under King Addons. Page slugs
200 + // do not change.
201 + if (king_addons_woo_builder_menu_is_active()) {
220 202 $this->showWooBuilder();
221 203 }
222 204
205 + $wishlist_enabled = (!isset($options['ext_wishlist']) || $options['ext_wishlist'] === 'enabled')
206 + && (defined('KING_ADDONS_EXT_WISHLIST') ? KING_ADDONS_EXT_WISHLIST : true);
207 + if ($wishlist_enabled) {
208 + $woo_parent = king_addons_woo_admin_parent_slug();
209 + add_submenu_page(
210 + $woo_parent,
211 + esc_html__('Wishlist', 'king-addons'),
212 + esc_html__('Wishlist', 'king-addons'),
213 + 'manage_options',
214 + 'king-addons-wishlist',
215 + [$this, 'renderWishlistPage']
216 + );
217 +
218 + add_submenu_page(
219 + $woo_parent,
220 + esc_html__('Wishlist Analytics', 'king-addons'),
221 + esc_html__('Wishlist Analytics', 'king-addons'),
222 + 'manage_options',
223 + 'king-addons-wishlist-analytics',
224 + [$this, 'renderWishlistAnalyticsPage']
225 + );
226 + }
227 +
223 228 $menu['54.8'] = array( '', 'read', 'separator-king-addons-bottom', '', 'wp-menu-separator elementor' );
224 229
225 230 }
226 231
@@ -228,9 +233,11 @@
228 233 * Enforce submenu order for King Addons:
229 234 * 1) Dashboard
230 235 * 2) Settings
231 236 * 3) AI Settings
232 - * 4) Everything else alphabetically
237 + * 4) Account (Freemius)
238 + * 5) Contact Us (Freemius)
239 + * 6) Everything else alphabetically
233 240 */
234 241 public function reorderKingAddonsSubmenu(): void
235 242 {
236 243 global $submenu;
@@ -242,8 +249,10 @@
242 249 $priorityBySlug = [
243 250 'king-addons' => 0,
244 251 'king-addons-settings' => 1,
245 252 'king-addons-ai-settings' => 2,
253 + 'king-addons-account' => 3,
254 + 'king-addons-contact' => 4,
246 255 ];
247 256
248 257 $items = $submenu['king-addons'];
249 258
@@ -275,8 +284,61 @@
275 284
276 285 $submenu['king-addons'] = $items;
277 286 }
278 287
288 + /**
289 + * WooCommerce Builder submenu:
290 + * 1) Dashboard (same slug as the top-level page)
291 + * 2) Wishlist
292 + * 3) Wishlist Analytics
293 + * 4) Free Shipping Bar
294 + * 5) Sticky Add To Cart
295 + * 6) My Account Endpoints
296 + */
297 + public function reorderWooBuilderSubmenu(): void
298 + {
299 + global $submenu;
300 +
301 + if (!is_array($submenu) || empty($submenu['king-addons-woo-builder']) || !is_array($submenu['king-addons-woo-builder'])) {
302 + return;
303 + }
304 +
305 + $priorityBySlug = [
306 + 'king-addons-woo-builder' => 0,
307 + 'king-addons-wishlist' => 1,
308 + 'king-addons-wishlist-analytics' => 2,
309 + 'king-addons-free-shipping-bar' => 3,
310 + 'king-addons-sticky-add-to-cart' => 4,
311 + 'king-addons-myaccount-endpoints' => 5,
312 + ];
313 +
314 + $items = $submenu['king-addons-woo-builder'];
315 +
316 + usort($items, static function ($a, $b) use ($priorityBySlug): int {
317 + $aSlug = isset($a[2]) ? (string) $a[2] : '';
318 + $bSlug = isset($b[2]) ? (string) $b[2] : '';
319 +
320 + $aPriority = array_key_exists($aSlug, $priorityBySlug) ? $priorityBySlug[$aSlug] : 9999;
321 + $bPriority = array_key_exists($bSlug, $priorityBySlug) ? $priorityBySlug[$bSlug] : 9999;
322 +
323 + if ($aPriority !== $bPriority) {
324 + return $aPriority <=> $bPriority;
325 + }
326 +
327 + $aLabel = isset($a[0]) ? wp_strip_all_tags((string) $a[0]) : '';
328 + $bLabel = isset($b[0]) ? wp_strip_all_tags((string) $b[0]) : '';
329 +
330 + $cmp = strcasecmp($aLabel, $bLabel);
331 + if ($cmp !== 0) {
332 + return $cmp;
333 + }
334 +
335 + return strcasecmp($aSlug, $bSlug);
336 + });
337 +
338 + $submenu['king-addons-woo-builder'] = $items;
339 + }
340 +
279 341 function addUpgradeMenu(): void
280 342 {
281 343 // Don't add menu if Freemius is showing opt-in/activation
282 344 $fs = king_addons_freemius();
@@ -331,8 +393,19 @@
331 393 [$this, 'renderWooBuilderPage'],
332 394 'dashicons-cart',
333 395 54.5
334 396 );
397 +
398 + // Same slug as the parent so the first submenu item is "Dashboard"
399 + // instead of repeating "WooCommerce Builder".
400 + add_submenu_page(
401 + 'king-addons-woo-builder',
402 + esc_html__('WooCommerce Builder', 'king-addons'),
403 + esc_html__('Dashboard', 'king-addons'),
404 + 'manage_options',
405 + 'king-addons-woo-builder',
406 + [$this, 'renderWooBuilderPage']
407 + );
335 408 }
336 409
337 410 /**
338 411 * Render WooCommerce Builder admin page.
@@ -349,30 +422,41 @@
349 422 }
350 423
351 424 function showHeaderFooterBuilder(): void
352 425 {
353 - $post_type = 'king-addons-el-hf';
354 - $menu_slug = 'edit.php?post_type=' . $post_type;
426 + $menu_slug = 'king-addons-el-hf';
427 + $callback = [\King_Addons\Header_Footer_Builder::instance(), 'renderAdminPage'];
355 428
356 - // Add Main Menu
429 + // Add Main Menu (new unified UI)
357 430 add_menu_page(
358 431 esc_html__('Elementor Header & Footer Builder', 'king-addons'),
359 432 esc_html__('Header & Footer', 'king-addons'),
360 433 'manage_options',
361 - $menu_slug, // Menu slug points to the custom post type edit screen
362 - '', // No callback function needed
434 + $menu_slug,
435 + $callback,
363 436 KING_ADDONS_URL . 'includes/admin/img/icon-for-header-footer-builder.svg',
364 437 54.3
365 438 );
366 439
367 - // Add 'All Templates' Submenu - this will be the first submenu item
440 + // Ensure the first submenu item is labeled "Templates"
368 441 add_submenu_page(
369 - $menu_slug, // Parent slug matches the main menu slug
370 - esc_html__('All Templates', 'king-addons'),
371 - esc_html__('All Templates', 'king-addons'),
372 - 'edit_posts',
373 - $menu_slug
442 + $menu_slug,
443 + esc_html__('Templates', 'king-addons'),
444 + esc_html__('Templates', 'king-addons'),
445 + 'manage_options',
446 + $menu_slug,
447 + $callback
374 448 );
449 +
450 + // Display Settings submenu (same page, different tab)
451 + add_submenu_page(
452 + $menu_slug,
453 + esc_html__('Display Settings', 'king-addons'),
454 + esc_html__('Display Settings', 'king-addons'),
455 + 'manage_options',
456 + $menu_slug . '&tab=settings',
457 + $callback
458 + );
375 459 }
376 460
377 461 function showSettingsPage(): void
378 462 {
@@ -394,8 +478,90 @@
394 478 require_once(KING_ADDONS_PATH . 'includes/admin/layouts/dashboard-v3/dashboard-v3.php');
395 479 }
396 480
397 481 /**
482 + * Handle AJAX request to install/activate Trinity Backup plugin.
483 + *
484 + * @return void
485 + */
486 + public function handleInstallTrinityBackup(): void
487 + {
488 + check_ajax_referer('king_addons_install_plugin', 'nonce');
489 +
490 + if (!current_user_can('install_plugins')) {
491 + wp_send_json_error(esc_html__('Permission denied.', 'king-addons'));
492 + }
493 +
494 + $plugin_action = isset($_POST['plugin_action']) ? sanitize_text_field($_POST['plugin_action']) : '';
495 + $plugin_slug = 'trinity-backup';
496 + $plugin_file = 'trinity-backup/trinity-backup.php';
497 + $plugin_path = WP_PLUGIN_DIR . '/' . $plugin_file;
498 +
499 + // If action is activate and plugin exists, just activate it
500 + if ($plugin_action === 'activate') {
501 + if (file_exists($plugin_path)) {
502 + $result = activate_plugin($plugin_file);
503 + if (is_wp_error($result)) {
504 + wp_send_json_error($result->get_error_message());
505 + }
506 + wp_send_json_success(['message' => esc_html__('Plugin activated!', 'king-addons')]);
507 + } else {
508 + wp_send_json_error(esc_html__('Plugin not found.', 'king-addons'));
509 + }
510 + return;
511 + }
512 +
513 + // Install plugin
514 + require_once ABSPATH . 'wp-admin/includes/plugin-install.php';
515 + require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
516 + require_once ABSPATH . 'wp-admin/includes/class-wp-ajax-upgrader-skin.php';
517 +
518 + // Get plugin info from WordPress.org
519 + $api = plugins_api('plugin_information', [
520 + 'slug' => $plugin_slug,
521 + 'fields' => [
522 + 'short_description' => false,
523 + 'sections' => false,
524 + 'requires' => false,
525 + 'rating' => false,
526 + 'ratings' => false,
527 + 'downloaded' => false,
528 + 'last_updated' => false,
529 + 'added' => false,
530 + 'tags' => false,
531 + 'compatibility' => false,
532 + 'homepage' => false,
533 + 'donate_link' => false,
534 + ],
535 + ]);
536 +
537 + if (is_wp_error($api)) {
538 + wp_send_json_error($api->get_error_message());
539 + }
540 +
541 + $skin = new \WP_Ajax_Upgrader_Skin();
542 + $upgrader = new \Plugin_Upgrader($skin);
543 + $result = $upgrader->install($api->download_link);
544 +
545 + if (is_wp_error($result)) {
546 + wp_send_json_error($result->get_error_message());
547 + }
548 +
549 + if ($result === false) {
550 + wp_send_json_error(esc_html__('Installation failed.', 'king-addons'));
551 + }
552 +
553 + // Activate the plugin after installation
554 + $activate_result = activate_plugin($plugin_file);
555 + if (is_wp_error($activate_result)) {
556 + // Installed but not activated
557 + wp_send_json_success(['message' => esc_html__('Plugin installed! Please activate manually.', 'king-addons')]);
558 + }
559 +
560 + wp_send_json_success(['message' => esc_html__('Plugin installed and activated!', 'king-addons')]);
561 + }
562 +
563 + /**
398 564 * Render Wishlist admin page.
399 565 *
400 566 * @return void
401 567 */
@@ -555,8 +721,9 @@
555 721 'button_added_text' => sanitize_text_field($settings['button_added_text'] ?? $defaults['button_added_text']),
556 722 'button_display_mode' => in_array($settings['button_display_mode'] ?? 'icon_text', ['icon', 'icon_text'], true) ? $settings['button_display_mode'] : $defaults['button_display_mode'],
557 723 'button_position' => in_array($settings['button_position'] ?? 'after_add_to_cart', ['before_add_to_cart', 'after_add_to_cart'], true) ? $settings['button_position'] : $defaults['button_position'],
558 724 'show_in_archives' => !empty($settings['show_in_archives']),
725 + 'show_on_single' => !empty($settings['show_on_single']),
559 726 'wishlist_columns' => array_values(
560 727 array_intersect(
561 728 (array) ($settings['wishlist_columns'] ?? []),
562 729 ['image', 'title', 'price', 'stock', 'notes', 'add_to_cart', 'remove']
@@ -636,35 +803,120 @@
636 803 );
637 804
638 805 add_settings_section(
639 806 'king_addons_ai_openai_section',
640 - esc_html__('OpenAI API Settings', 'king-addons'),
807 + esc_html__('AI Provider Settings', 'king-addons'),
641 808 [$this, 'renderAiOpenaiSection'],
642 809 'king-addons-ai-settings'
643 810 );
644 811
645 812 add_settings_field(
813 + 'ai_provider',
814 + esc_html__('AI Provider', 'king-addons'),
815 + [$this, 'renderAiProviderField'],
816 + 'king-addons-ai-settings',
817 + 'king_addons_ai_openai_section'
818 + );
819 +
820 + // Provider specific rows. The row classes let the settings page show only
821 + // the fields belonging to the provider that is currently selected; the
822 + // inactive ones start hidden so they never flash before the script runs.
823 + $active_provider = AI_Provider::getProvider();
824 + $row_class = static function (string $provider) use ($active_provider): array {
825 + $classes = 'ka-ai-provider-row ka-ai-provider-' . $provider;
826 + if ($provider !== $active_provider) {
827 + $classes .= ' ka-ai-row-hidden';
828 + }
829 + return ['class' => $classes];
830 + };
831 +
832 + add_settings_field(
646 833 'openai_api_key',
647 834 esc_html__('OpenAI API Key', 'king-addons'),
648 835 [$this, 'renderAiApiKeyField'],
649 836 'king-addons-ai-settings',
837 + 'king_addons_ai_openai_section',
838 + $row_class(AI_Provider::OPENAI)
839 + );
840 +
841 + add_settings_field(
842 + 'openrouter_api_key',
843 + esc_html__('OpenRouter API Key', 'king-addons'),
844 + [$this, 'renderAiOpenRouterApiKeyField'],
845 + 'king-addons-ai-settings',
846 + 'king_addons_ai_openai_section',
847 + $row_class(AI_Provider::OPENROUTER)
848 + );
849 +
850 + add_settings_field(
851 + 'ai_connection_test',
852 + esc_html__('Connection', 'king-addons'),
853 + [$this, 'renderAiConnectionTestField'],
854 + 'king-addons-ai-settings',
650 855 'king_addons_ai_openai_section'
651 856 );
652 857
653 858 add_settings_field(
654 859 'openai_model',
655 - esc_html__('OpenAI Model', 'king-addons'),
860 + esc_html__('OpenAI Model (for text generation)', 'king-addons'),
656 861 [$this, 'renderAiModelField'],
657 862 'king-addons-ai-settings',
658 - 'king_addons_ai_openai_section'
863 + 'king_addons_ai_openai_section',
864 + $row_class(AI_Provider::OPENAI)
659 865 );
660 866
867 + add_settings_field(
868 + 'openai_vision_model',
869 + esc_html__('OpenAI Model (for image recognition)', 'king-addons'),
870 + [$this, 'renderAiVisionModelField'],
871 + 'king-addons-ai-settings',
872 + 'king_addons_ai_openai_section',
873 + $row_class(AI_Provider::OPENAI)
874 + );
875 +
661 876 // Add image model selector field
662 877 add_settings_field(
663 878 'openai_image_model',
664 - esc_html__('OpenAI Image Model', 'king-addons'),
879 + esc_html__('OpenAI Image Model (for image generation)', 'king-addons'),
665 880 [$this, 'renderAiImageModelField'],
666 881 'king-addons-ai-settings',
882 + 'king_addons_ai_openai_section',
883 + $row_class(AI_Provider::OPENAI)
884 + );
885 +
886 + add_settings_field(
887 + 'openrouter_model',
888 + esc_html__('OpenRouter Model (for text generation)', 'king-addons'),
889 + [$this, 'renderAiOpenRouterModelField'],
890 + 'king-addons-ai-settings',
891 + 'king_addons_ai_openai_section',
892 + $row_class(AI_Provider::OPENROUTER)
893 + );
894 +
895 + add_settings_field(
896 + 'openrouter_vision_model',
897 + esc_html__('OpenRouter Model (for image recognition)', 'king-addons'),
898 + [$this, 'renderAiOpenRouterVisionModelField'],
899 + 'king-addons-ai-settings',
900 + 'king_addons_ai_openai_section',
901 + $row_class(AI_Provider::OPENROUTER)
902 + );
903 +
904 + add_settings_field(
905 + 'openrouter_image_model',
906 + esc_html__('OpenRouter Image Model (for image generation)', 'king-addons'),
907 + [$this, 'renderAiOpenRouterImageModelField'],
908 + 'king-addons-ai-settings',
909 + 'king_addons_ai_openai_section',
910 + $row_class(AI_Provider::OPENROUTER)
911 + );
912 +
913 + // Global content language
914 + add_settings_field(
915 + 'content_language_custom_enable',
916 + esc_html__('Content Language', 'king-addons'),
917 + [$this, 'renderAiContentLanguageField'],
918 + 'king-addons-ai-settings',
667 919 'king_addons_ai_openai_section'
668 920 );
669 921
670 922 // Add Editor Integration section and field
@@ -725,9 +977,8 @@
725 977 [$this, 'renderAiAltTextImageDetailLevelField'],
726 978 'king-addons-ai-settings',
727 979 'king_addons_ai_alt_text_section'
728 980 );
729 -
730 981 // Add Translation Settings section
731 982 add_settings_section(
732 983 'king_addons_ai_translation_section',
733 984 esc_html__('Translation Settings', 'king-addons'),
@@ -772,8 +1023,11 @@
772 1023
773 1024 // AJAX handler for refreshing models.
774 1025 add_action('wp_ajax_king_addons_ai_refresh_models', [$this, 'handleAiRefreshModels']);
775 1026
1027 + // AJAX handler for testing the provider connection.
1028 + add_action('wp_ajax_king_addons_ai_test_connection', [$this, 'handleAiTestConnection']);
1029 +
776 1030 // AJAX handler for generating text via AI
777 1031 add_action('wp_ajax_king_addons_ai_generate_text', [$this, 'handleAiGenerateText']);
778 1032
779 1033 // AJAX handler to change text using AI based on user prompt and original text.
@@ -800,8 +1054,12 @@
800 1054 */
801 1055 public function sanitizeAiSettings(array $input): array
802 1056 {
803 1057 $sanitized = [];
1058 +
1059 + // An absent value resolves to the default rather than being pinned to OpenAI.
1060 + $sanitized['ai_provider'] = AI_Provider::normalizeProvider($input['ai_provider'] ?? '');
1061 +
804 1062 $sanitized['openai_api_key'] = isset($input['openai_api_key'])
805 1063 ? sanitize_text_field($input['openai_api_key'])
806 1064 : '';
807 1065 $sanitized['openai_model'] = isset($input['openai_model'])
@@ -806,12 +1064,36 @@
806 1064 : '';
807 1065 $sanitized['openai_model'] = isset($input['openai_model'])
808 1066 ? sanitize_text_field($input['openai_model'])
809 1067 : '';
1068 + $sanitized['openai_vision_model'] = isset($input['openai_vision_model'])
1069 + ? sanitize_text_field($input['openai_vision_model'])
1070 + : ($sanitized['openai_model'] ?: 'gpt-4o-mini');
810 1071 $sanitized['openai_image_model'] = isset($input['openai_image_model'])
811 1072 ? sanitize_text_field($input['openai_image_model'])
812 1073 : 'gpt-image-1';
813 1074
1075 + // OpenRouter is configured independently, so switching providers back
1076 + // and forth keeps both sets of credentials and models intact.
1077 + $sanitized['openrouter_api_key'] = isset($input['openrouter_api_key'])
1078 + ? sanitize_text_field($input['openrouter_api_key'])
1079 + : '';
1080 + $sanitized['openrouter_model'] = isset($input['openrouter_model'])
1081 + ? sanitize_text_field($input['openrouter_model'])
1082 + : '';
1083 + $sanitized['openrouter_vision_model'] = isset($input['openrouter_vision_model'])
1084 + ? sanitize_text_field($input['openrouter_vision_model'])
1085 + : ($sanitized['openrouter_model'] ?: '');
1086 + $sanitized['openrouter_image_model'] = isset($input['openrouter_image_model'])
1087 + ? sanitize_text_field($input['openrouter_image_model'])
1088 + : '';
1089 +
1090 + // Content language is rendered by this section, so it has to survive the save.
1091 + $sanitized['content_language_custom_enable'] = !empty($input['content_language_custom_enable']);
1092 + $sanitized['content_language_custom'] = isset($input['content_language_custom'])
1093 + ? sanitize_text_field($input['content_language_custom'])
1094 + : '';
1095 +
814 1096 // Sanitize Daily Token Limit.
815 1097 if (isset($input['daily_token_limit'])) {
816 1098 $daily_limit = absint($input['daily_token_limit']);
817 1099 $sanitized['daily_token_limit'] = max(0, $daily_limit); // Ensure non-negative
@@ -835,16 +1117,27 @@
835 1117 if (isset($input['ai_alt_text_generation_interval'])) {
836 1118 $interval = absint($input['ai_alt_text_generation_interval']);
837 1119 $sanitized['ai_alt_text_generation_interval'] = max(10, min(3600, $interval)); // Between 10 seconds and 1 hour
838 1120 } else {
839 - $sanitized['ai_alt_text_generation_interval'] = 60; // Default to 60 seconds
1121 + $sanitized['ai_alt_text_generation_interval'] = 20; // Default to 20 seconds
840 1122 }
841 1123 // Sanitize Image Detail Level
842 1124 $allowed_detail_levels = ['low', 'high'];
843 1125 $sanitized['ai_alt_text_image_detail_level'] = in_array(($input['ai_alt_text_image_detail_level'] ?? 'low'), $allowed_detail_levels, true)
844 - ? $input['ai_alt_text_image_detail_level']
1126 + ? ($input['ai_alt_text_image_detail_level'] ?? 'low')
845 1127 : 'low';
846 1128
1129 + // Sanitize Auto Tagging settings.
1130 + $sanitized['auto_tagging_max_tags'] = isset($input['auto_tagging_max_tags'])
1131 + ? max(1, min(20, absint($input['auto_tagging_max_tags'])))
1132 + : 5;
1133 + $sanitized['auto_tagging_confidence_threshold'] = isset($input['auto_tagging_confidence_threshold'])
1134 + ? max(0.0, min(1.0, (float) $input['auto_tagging_confidence_threshold']))
1135 + : 0.75;
1136 + $sanitized['auto_tagging_stop_words'] = isset($input['auto_tagging_stop_words'])
1137 + ? sanitize_text_field($input['auto_tagging_stop_words'])
1138 + : '';
1139 +
847 1140 // Sanitize Enable AI Page Translator option
848 1141 $sanitized['enable_ai_page_translator'] = !empty($input['enable_ai_page_translator']);
849 1142
850 1143 // Validation / feedback (Settings API notice).
@@ -854,13 +1147,21 @@
854 1147 || !empty($sanitized['enable_ai_alt_text_button'])
855 1148 || !empty($sanitized['enable_ai_page_translator'])
856 1149 );
857 1150
858 - if ($ai_requires_key && empty($sanitized['openai_api_key'])) {
1151 + $active_key = ($sanitized['ai_provider'] === AI_Provider::OPENROUTER)
1152 + ? $sanitized['openrouter_api_key']
1153 + : $sanitized['openai_api_key'];
1154 +
1155 + if ($ai_requires_key && empty($active_key)) {
859 1156 add_settings_error(
860 1157 'king_addons_ai',
861 1158 'king_addons_ai_missing_api_key',
862 - esc_html__('OpenAI API Key is required to enable AI features.', 'king-addons'),
1159 + sprintf(
1160 + /* translators: %s: provider name */
1161 + esc_html__('%s API Key is required to enable AI features.', 'king-addons'),
1162 + AI_Provider::getLabel($sanitized['ai_provider'])
1163 + ),
863 1164 'error'
864 1165 );
865 1166 }
866 1167
@@ -873,9 +1174,9 @@
873 1174 * @return void
874 1175 */
875 1176 public function clearAiModelsCache(): void
876 1177 {
877 - delete_transient('king_addons_ai_models_cache');
1178 + AI_Provider::clearModelsCache();
878 1179 }
879 1180
880 1181 /**
881 1182 * Renders the AI Settings page content.
@@ -924,23 +1225,162 @@
924 1225 'nonce' => wp_create_nonce('king_addons_ai_refresh_models_nonce'),
925 1226 'refreshing_text' => esc_html__('Refreshing...', 'king-addons'),
926 1227 'refreshed_text' => esc_html__('List updated.', 'king-addons'),
927 1228 'error_text' => esc_html__('Error updating list.', 'king-addons'),
1229 + 'testing_text' => esc_html__('Testing connection...', 'king-addons'),
1230 + 'test_failed_text' => esc_html__('Connection failed.', 'king-addons'),
1231 + 'free_models_label' => esc_html__('Free models', 'king-addons'),
1232 + 'paid_models_label' => esc_html__('Paid models', 'king-addons'),
928 1233 ]
929 1234 );
930 1235 }
931 1236
932 1237 /**
933 - * Renders description for OpenAI API Settings section.
1238 + * Renders description for the AI Provider Settings section.
934 1239 *
935 1240 * @return void
936 1241 */
937 1242 public function renderAiOpenaiSection(): void
938 1243 {
939 - echo '<p>' . esc_html__('Enter your OpenAI API key and select the model for AI features.', 'king-addons') . '</p>';
1244 + echo '<p>' . esc_html__('Choose an AI provider, enter its API key and select the models used by the AI features.', 'king-addons') . '</p>';
940 1245 }
941 1246
942 1247 /**
1248 + * Renders the AI provider selector.
1249 + *
1250 + * @return void
1251 + */
1252 + public function renderAiProviderField(): void
1253 + {
1254 + $selected = AI_Provider::getProvider();
1255 +
1256 + echo '<select name="king_addons_ai_options[ai_provider]" id="king-addons-ai-provider">';
1257 + foreach (AI_Provider::getProviders() as $provider => $label) {
1258 + printf(
1259 + '<option value="%s" %s>%s</option>',
1260 + esc_attr($provider),
1261 + selected($selected, $provider, false),
1262 + esc_html($label)
1263 + );
1264 + }
1265 + echo '</select>';
1266 +
1267 + echo '<p class="description">' . esc_html__('OpenAI talks to the OpenAI API directly. OpenRouter is a gateway to models from many vendors, including a number of free ones, through a single API key.', 'king-addons') . '</p>';
1268 + }
1269 +
1270 + /**
1271 + * Renders the OpenRouter API Key input field.
1272 + *
1273 + * @return void
1274 + */
1275 + public function renderAiOpenRouterApiKeyField(): void
1276 + {
1277 + $options = get_option('king_addons_ai_options', []);
1278 + $api_key = $options['openrouter_api_key'] ?? '';
1279 + printf(
1280 + '<input type="password" name="king_addons_ai_options[openrouter_api_key]" id="king-addons-openrouter-api-key" value="%s" class="regular-text" autocomplete="off" />',
1281 + esc_attr($api_key)
1282 + );
1283 + echo '<p class="description">';
1284 + printf(
1285 + /* translators: %1$s: opening link tag, %2$s: closing link tag */
1286 + esc_html__('Get your API key from %1$sOpenRouter Keys%2$s. Saving the key will attempt to fetch the available models.', 'king-addons'),
1287 + '<a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer">',
1288 + '</a>'
1289 + );
1290 + echo '</p>';
1291 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1292 + echo '<strong>' . esc_html__('Info:', 'king-addons') . '</strong> ';
1293 + echo esc_html__('Free models can be used without adding credit. Paid models, including every image generation model, require credit on your OpenRouter account.', 'king-addons');
1294 + echo '</div>';
1295 + echo '<div class="ka-ai-notice ka-ai-notice-info">';
1296 + echo '<strong class="ka-ai-notice-title">' . esc_html__('Useful OpenRouter Links:', 'king-addons') . '</strong>';
1297 + echo '<ul class="ka-ai-links-list">';
1298 + $links = [
1299 + 'Models & Pricing' => 'https://openrouter.ai/models',
1300 + 'API Keys' => 'https://openrouter.ai/keys',
1301 + 'Activity Dashboard' => 'https://openrouter.ai/activity',
1302 + 'Credits' => 'https://openrouter.ai/settings/credits',
1303 + 'Limits' => 'https://openrouter.ai/docs/api-reference/limits',
1304 + ];
1305 + foreach ($links as $label => $url) {
1306 + printf(
1307 + '<li><a href="%s" target="_blank" rel="noopener noreferrer">%s</a></li>',
1308 + esc_url($url),
1309 + esc_html($label)
1310 + );
1311 + }
1312 + echo '</ul></div>';
1313 + }
1314 +
1315 + /**
1316 + * Renders the "Test Connection" control.
1317 + *
1318 + * @return void
1319 + */
1320 + public function renderAiConnectionTestField(): void
1321 + {
1322 + echo '<button type="button" id="king-addons-ai-test-connection-button" class="button button-secondary">' . esc_html__('Test Connection', 'king-addons') . '</button>';
1323 + echo '<button type="button" id="king-addons-ai-refresh-models-button" class="button button-secondary" style="margin-left:10px; vertical-align:middle;">' . esc_html__('Refresh List', 'king-addons') . '</button>';
1324 + echo '<span class="spinner" id="king-addons-ai-test-connection-spinner" style="float:none; vertical-align:middle;"></span>';
1325 + echo '<span class="spinner" id="king-addons-ai-refresh-models-spinner" style="float:none; vertical-align:middle;"></span>';
1326 + echo '<span id="king-addons-ai-test-connection-status" style="margin-left:5px; vertical-align:middle;"></span>';
1327 + echo '<span id="king-addons-ai-refresh-models-status" style="margin-left:5px; vertical-align:middle;"></span>';
1328 + echo '<p class="description">' . esc_html__('Test Connection checks the API key currently typed above against the selected provider — it does not have to be saved first. Refresh List re-fetches that provider\'s model catalogue.', 'king-addons') . '</p>';
1329 + }
1330 +
1331 + /**
1332 + * Renders the OpenRouter text model dropdown.
1333 + *
1334 + * @return void
1335 + */
1336 + public function renderAiOpenRouterModelField(): void
1337 + {
1338 + $selected = AI_Provider::getModel('text', AI_Provider::OPENROUTER);
1339 + AI_Provider::renderModelSelect(
1340 + 'king_addons_ai_options[openrouter_model]',
1341 + $selected,
1342 + AI_Provider::getModelsFor('text', AI_Provider::OPENROUTER),
1343 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'text']
1344 + );
1345 + echo '<p class="description">' . esc_html__('Free models are listed first, then paid ones, each block in alphabetical order. Use the Refresh List button to pull the latest catalogue.', 'king-addons') . '</p>';
1346 + }
1347 +
1348 + /**
1349 + * Renders the OpenRouter vision model dropdown.
1350 + *
1351 + * @return void
1352 + */
1353 + public function renderAiOpenRouterVisionModelField(): void
1354 + {
1355 + $selected = AI_Provider::getModel('vision', AI_Provider::OPENROUTER);
1356 + AI_Provider::renderModelSelect(
1357 + 'king_addons_ai_options[openrouter_vision_model]',
1358 + $selected,
1359 + AI_Provider::getModelsFor('vision', AI_Provider::OPENROUTER),
1360 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'vision']
1361 + );
1362 + echo '<p class="description">' . esc_html__('Only models that accept image input are listed. Used for the Alt Text Generator and other vision requests.', 'king-addons') . '</p>';
1363 + }
1364 +
1365 + /**
1366 + * Renders the OpenRouter image generation model dropdown.
1367 + *
1368 + * @return void
1369 + */
1370 + public function renderAiOpenRouterImageModelField(): void
1371 + {
1372 + $selected = AI_Provider::getModel('image', AI_Provider::OPENROUTER);
1373 + AI_Provider::renderModelSelect(
1374 + 'king_addons_ai_options[openrouter_image_model]',
1375 + $selected,
1376 + AI_Provider::getModelsFor('image', AI_Provider::OPENROUTER),
1377 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'image']
1378 + );
1379 + echo '<p class="description">' . esc_html__('Only models that return images are listed. Image generation is never free — your OpenRouter account needs credit.', 'king-addons') . '</p>';
1380 + }
1381 +
1382 + /**
943 1383 * Renders the OpenAI API Key input field.
944 1384 *
945 1385 * @return void
946 1386 */
@@ -993,96 +1433,53 @@
993 1433 * @return void
994 1434 */
995 1435 public function renderAiModelField(): void
996 1436 {
997 - $options = get_option('king_addons_ai_options', []);
998 - $selected = $options['openai_model'] ?? '';
999 - $models = $this->getAiAvailableModels();
1000 - printf(
1001 - '<select name="king_addons_ai_options[openai_model]" %s>',
1002 - empty($models) ? 'disabled' : ''
1437 + $selected = AI_Provider::getModel('text', AI_Provider::OPENAI);
1438 + AI_Provider::renderModelSelect(
1439 + 'king_addons_ai_options[openai_model]',
1440 + $selected,
1441 + AI_Provider::getModelsFor('text', AI_Provider::OPENAI),
1442 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'text']
1003 1443 );
1004 - if (!empty($models)) {
1005 - foreach ($models as $id => $label) {
1006 - printf(
1007 - '<option value="%s" %s>%s</option>',
1008 - esc_attr($id),
1009 - selected($selected, $id, false),
1010 - esc_html($label)
1011 - );
1012 - }
1013 - } else {
1014 - echo '<option value="">' . esc_html__('Could not fetch models. Check API key?', 'king-addons') . '</option>';
1015 - }
1016 - echo '</select>';
1017 - echo '<button type="button" id="king-addons-ai-refresh-models-button" class="button button-secondary" style="margin-left:10px; vertical-align:middle;">' . esc_html__('Refresh List', 'king-addons') . '</button>';
1018 - echo '<span class="spinner" id="king-addons-ai-refresh-models-spinner" style="float:none; vertical-align:middle;"></span>';
1019 - echo '<span id="king-addons-ai-refresh-models-status" style="margin-left:5px; vertical-align:middle;"></span>';
1020 1444 echo '<p class="description">' . esc_html__('Select an available OpenAI model capable of processing text. We recommend GPT-4o-mini or GPT-4.1-nano for best results. The list of models is cached indefinitely until manually refreshed.', 'king-addons') . '</p>';
1021 1445 }
1022 1446
1023 1447 /**
1024 - * Fetches the list of OpenAI models via API.
1448 + * Renders the vision model selection dropdown field.
1025 1449 *
1026 - * @param string|null $api_key API key to use.
1027 - * @return array|\WP_Error Model list or error.
1450 + * @return void
1028 1451 */
1029 - private function fetchAiOpenaiModels(?string $api_key)
1452 + public function renderAiVisionModelField(): void
1030 1453 {
1031 - if (empty($api_key)) {
1032 - return new \WP_Error('missing_key', esc_html__('API key is required to fetch models.', 'king-addons'));
1033 - }
1034 - $endpoint = 'https://api.openai.com/v1/models';
1035 - $response = wp_remote_get($endpoint, [
1036 - 'headers' => ['Authorization' => 'Bearer ' . $api_key],
1037 - 'timeout' => 20,
1038 - ]);
1039 - if (is_wp_error($response)) {
1040 - return $response;
1041 - }
1042 - $code = wp_remote_retrieve_response_code($response);
1043 - $body = wp_remote_retrieve_body($response);
1044 - $data = json_decode($body, true);
1045 - if ($code !== 200 || empty($data['data']) || !is_array($data['data'])) {
1046 - $message = $data['error']['message'] ?? esc_html__('Invalid response from API.', 'king-addons');
1047 - return new \WP_Error('api_error', $message, ['status' => $code]);
1048 - }
1049 - $list = [];
1050 - foreach ($data['data'] as $model) {
1051 - if (isset($model['id'])) {
1052 - $list[$model['id']] = $model['id'];
1053 - }
1054 - }
1055 - ksort($list);
1056 - if (empty($list)) {
1057 - return new \WP_Error('no_models', esc_html__('No models found via API.', 'king-addons'));
1058 - }
1059 - return $list;
1454 + $options = get_option('king_addons_ai_options', []);
1455 + $selected = $options['openai_vision_model'] ?? ($options['openai_model'] ?? 'gpt-4o-mini');
1456 + AI_Provider::renderModelSelect(
1457 + 'king_addons_ai_options[openai_vision_model]',
1458 + (string) $selected,
1459 + AI_Provider::getModelsFor('vision', AI_Provider::OPENAI),
1460 + ['class' => 'ka-ai-model-select', 'data-ka-model-type' => 'vision']
1461 + );
1462 + echo '<p class="description">' . esc_html__('Select the default model used for AI image analysis tasks (Alt Text Generator and related vision requests).', 'king-addons') . '</p>';
1060 1463 }
1061 1464
1062 1465 /**
1063 - * Retrieves available models, using cache if possible.
1466 + * Builds the model lists the settings page needs, keyed by purpose.
1064 1467 *
1065 - * @return array Model list.
1468 + * @param string $provider Provider slug.
1469 + * @return array<string, array<int, array<string, mixed>>>
1066 1470 */
1067 - private function getAiAvailableModels(): array
1471 + private function getAiModelPayload(string $provider): array
1068 1472 {
1069 - $cached = get_transient('king_addons_ai_models_cache');
1070 - if (false !== $cached && is_array($cached)) {
1071 - return $cached;
1072 - }
1073 - $options = get_option('king_addons_ai_options', []);
1074 - $api_key = $options['openai_api_key'] ?? null;
1075 - $fetched = $this->fetchAiOpenaiModels($api_key);
1076 - if (!is_wp_error($fetched)) {
1077 - set_transient('king_addons_ai_models_cache', $fetched, 0);
1078 - return $fetched;
1079 - }
1080 - return ['gpt-4o-mini' => 'GPT-4o-mini', 'gpt-4.1-nano' => 'GPT-4.1-nano'];
1473 + return [
1474 + 'text' => AI_Provider::getModelsFor('text', $provider),
1475 + 'vision' => AI_Provider::getModelsFor('vision', $provider),
1476 + 'image' => AI_Provider::getModelsFor('image', $provider),
1477 + ];
1081 1478 }
1082 1479
1083 1480 /**
1084 - * Handles AJAX request to refresh model list.
1481 + * Handles AJAX request to refresh the model list of the selected provider.
1085 1482 *
1086 1483 * @return void
1087 1484 */
1088 1485 public function handleAiRefreshModels(): void
@@ -1090,27 +1487,76 @@
1090 1487 check_ajax_referer('king_addons_ai_refresh_models_nonce', 'nonce');
1091 1488 if (!current_user_can('manage_options')) {
1092 1489 wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
1093 1490 }
1094 - $options = get_option('king_addons_ai_options', []);
1095 - $api_key = $options['openai_api_key'] ?? null;
1096 - if (empty($api_key)) {
1491 +
1492 + $provider = AI_Provider::normalizeProvider(
1493 + isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : AI_Provider::getProvider()
1494 + );
1495 +
1496 + // Allow refreshing against a key that has been typed but not saved yet.
1497 + $posted_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : '';
1498 + $api_key = ($posted_key !== '') ? $posted_key : AI_Provider::getApiKey($provider);
1499 +
1500 + // OpenRouter publishes its catalogue without authentication.
1501 + if ($api_key === '' && $provider !== AI_Provider::OPENROUTER) {
1097 1502 wp_send_json_error(['message' => esc_html__('API key is not set.', 'king-addons')], 400);
1098 1503 }
1099 - $this->clearAiModelsCache();
1100 - $models = $this->fetchAiOpenaiModels($api_key);
1504 +
1505 + $models = AI_Provider::fetchModels($provider, $api_key);
1101 1506 if (is_wp_error($models)) {
1102 1507 wp_send_json_error(['message' => $models->get_error_message()], 500);
1103 1508 }
1104 - if (empty($models)) {
1105 - wp_send_json_error(['message' => esc_html__('No models returned by API.', 'king-addons')], 500);
1509 +
1510 + set_transient(
1511 + AI_Provider::getCacheKey($provider),
1512 + $models,
1513 + ($provider === AI_Provider::OPENROUTER) ? DAY_IN_SECONDS : 0
1514 + );
1515 +
1516 + wp_send_json_success([
1517 + 'provider' => $provider,
1518 + 'models' => $this->getAiModelPayload($provider),
1519 + ]);
1520 + }
1521 +
1522 + /**
1523 + * Handles AJAX request to verify the provider connection.
1524 + *
1525 + * @return void
1526 + */
1527 + public function handleAiTestConnection(): void
1528 + {
1529 + check_ajax_referer('king_addons_ai_refresh_models_nonce', 'nonce');
1530 + if (!current_user_can('manage_options')) {
1531 + wp_send_json_error(['message' => esc_html__('Permission denied.', 'king-addons')], 403);
1106 1532 }
1107 - set_transient('king_addons_ai_models_cache', $models, 0);
1108 - wp_send_json_success(['models' => $models]);
1533 +
1534 + $provider = AI_Provider::normalizeProvider(
1535 + isset($_POST['provider']) ? sanitize_text_field(wp_unslash($_POST['provider'])) : AI_Provider::getProvider()
1536 + );
1537 +
1538 + // Test what is in the field right now, so a key can be verified before
1539 + // it is saved. An empty field falls back to the stored key.
1540 + $posted_key = isset($_POST['api_key']) ? trim(sanitize_text_field(wp_unslash($_POST['api_key']))) : '';
1541 + $stored_key = AI_Provider::getApiKey($provider);
1542 + $api_key = ($posted_key !== '') ? $posted_key : $stored_key;
1543 + $unsaved = ($posted_key !== '' && $posted_key !== $stored_key);
1544 +
1545 + $result = AI_Provider::testConnection($provider, $api_key);
1546 + if (is_wp_error($result)) {
1547 + wp_send_json_error(['message' => $result->get_error_message()], 400);
1548 + }
1549 +
1550 + if ($unsaved) {
1551 + $result .= ' ' . esc_html__('Save the settings to start using it.', 'king-addons');
1552 + }
1553 +
1554 + wp_send_json_success(['message' => $result, 'unsaved' => $unsaved]);
1109 1555 }
1110 1556
1111 1557 /**
1112 - * AJAX handler to generate text using OpenAI.
1558 + * AJAX handler to generate text using the configured AI provider.
1113 1559 *
1114 1560 * @return void
1115 1561 */
1116 1562 public function handleAiGenerateText(): void
@@ -1128,10 +1574,10 @@
1128 1574 // Get editor type if provided
1129 1575 $editor_type = sanitize_text_field($_POST['editor_type'] ?? 'text');
1130 1576
1131 1577 $options = get_option('king_addons_ai_options', []);
1132 - $api_key = $options['openai_api_key'] ?? '';
1133 - $model = $options['openai_model'] ?? '';
1578 + $api_key = AI_Provider::getApiKey();
1579 + $model = AI_Provider::getTextModel();
1134 1580
1135 1581 if (empty($api_key) || empty($model)) {
1136 1582 wp_send_json_error(['message' => esc_html__('API key or model not set.', 'king-addons')], 400);
1137 1583 }
@@ -1145,9 +1591,13 @@
1145 1591 $current_usage = $this->getAiDailyUsage();
1146 1592
1147 1593 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
1148 1594 wp_send_json_error([
1149 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
1595 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
1596 + // This is the plugin's own cap, not the provider's, so the
1597 + // client must not retry it or blame the AI provider.
1598 + 'code' => 'local_limit',
1599 + 'retryable' => false,
1150 1600 ], 429);
1151 1601 }
1152 1602
1153 1603 // System instruction based on editor type
@@ -1157,9 +1607,9 @@
1157 1607 if ($editor_type === 'wysiwyg') {
1158 1608 $system_instruction = 'You are a helpful content assistant for a rich text editor. Provide content with proper HTML formatting. Use <p> tags for paragraphs with appropriate spacing between them. If relevant, use other HTML formatting like <strong>, <em>, <ul>, <ol>, etc. for better readability and structure. IMPORTANT: Do NOT wrap your HTML in code fences (``` or ```html). Respond ONLY with the actual HTML content.';
1159 1609 }
1160 1610
1161 - // Prepare request to OpenAI Chat Completions
1611 + // Prepare request to the provider's Chat Completions endpoint
1162 1612 $messages = [
1163 1613 ['role' => 'system', 'content' => $system_instruction],
1164 1614 ['role' => 'user', 'content' => $prompt]
1165 1615 ];
@@ -1169,20 +1619,17 @@
1169 1619 $messages[1]['content'] .= "\n\nOutput should be properly formatted HTML with <p> tags for paragraphs, maintaining good spacing and readability. Do NOT use code fences (```html or ```) in your response - provide just the clean HTML.";
1170 1620 }
1171 1621
1172 1622 $response = wp_remote_post(
1173 - 'https://api.openai.com/v1/chat/completions',
1623 + AI_Provider::getChatEndpoint(),
1174 1624 [
1175 - 'headers' => [
1176 - 'Authorization' => 'Bearer ' . $api_key,
1177 - 'Content-Type' => 'application/json',
1178 - ],
1179 - 'body' => wp_json_encode([
1625 + 'headers' => AI_Provider::getHeaders(),
1626 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload([
1180 1627 'model' => $model,
1181 1628 'messages' => $messages,
1182 1629 'max_tokens' => 500,
1183 1630 'temperature' => 0.7, // Slight creativity for better content
1184 - ]),
1631 + ])),
1185 1632 'timeout' => 30,
1186 1633 ]
1187 1634 );
1188 1635
@@ -1193,9 +1640,9 @@
1193 1640 $code = wp_remote_retrieve_response_code($response);
1194 1641 $data = json_decode(wp_remote_retrieve_body($response), true);
1195 1642
1196 1643 if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
1197 - $error_msg = $data['error']['message'] ?? esc_html__('AI API error.', 'king-addons');
1644 + $error_msg = AI_Provider::extractErrorMessage($data, esc_html__('AI API error.', 'king-addons'));
1198 1645 wp_send_json_error(['message' => $error_msg], 500);
1199 1646 }
1200 1647
1201 1648 $generated = trim($data['choices'][0]['message']['content']);
@@ -1238,10 +1685,10 @@
1238 1685 $original = isset($_POST['original']) ? wp_kses_post(wp_unslash($_POST['original'])) : '';
1239 1686 $instruction_context = isset($_POST['instruction_context']) ? sanitize_textarea_field(wp_unslash($_POST['instruction_context'])) : '';
1240 1687
1241 1688 $options = get_option('king_addons_ai_options', []);
1242 - $api_key = $options['openai_api_key'] ?? '';
1243 - $model = $options['openai_model'] ?? '';
1689 + $api_key = AI_Provider::getApiKey();
1690 + $model = AI_Provider::getTextModel();
1244 1691
1245 1692 if (empty($api_key) || empty($model) || empty($prompt) || empty($original)) {
1246 1693 wp_send_json_error(['message' => esc_html__('Missing data for AI change.', 'king-addons')], 400);
1247 1694 }
@@ -1251,9 +1698,13 @@
1251 1698 $current_usage = $this->getAiDailyUsage();
1252 1699
1253 1700 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
1254 1701 wp_send_json_error([
1255 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
1702 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
1703 + // This is the plugin's own cap, not the provider's, so the
1704 + // client must not retry it or blame the AI provider.
1705 + 'code' => 'local_limit',
1706 + 'retryable' => false,
1256 1707 ], 429);
1257 1708 }
1258 1709
1259 1710 // Default instruction if none provided
@@ -1504,15 +1955,12 @@
1504 1955 $body['max_tokens'] = 15000;
1505 1956 }
1506 1957
1507 1958 $response = wp_remote_post(
1508 - 'https://api.openai.com/v1/chat/completions',
1959 + AI_Provider::getChatEndpoint(),
1509 1960 [
1510 - 'headers' => [
1511 - 'Authorization' => 'Bearer ' . $api_key,
1512 - 'Content-Type' => 'application/json',
1513 - ],
1514 - 'body' => wp_json_encode($body),
1961 + 'headers' => AI_Provider::getHeaders(),
1962 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload($body)),
1515 1963 'timeout' => 30,
1516 1964 ]
1517 1965 );
1518 1966
@@ -1523,9 +1971,9 @@
1523 1971 $code = wp_remote_retrieve_response_code($response);
1524 1972 $data = json_decode(wp_remote_retrieve_body($response), true);
1525 1973
1526 1974 if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
1527 - $error_msg = $data['error']['message'] ?? esc_html__('AI change error.', 'king-addons');
1975 + $error_msg = AI_Provider::extractErrorMessage($data, esc_html__('AI change error.', 'king-addons'));
1528 1976 wp_send_json_error(['message' => $error_msg], 500);
1529 1977 }
1530 1978
1531 1979 $changed = trim($data['choices'][0]['message']['content']);
@@ -1701,10 +2149,10 @@
1701 2149 $options = get_option('king_addons_ai_options', []);
1702 2150 $daily_limit = isset($options['daily_token_limit']) ? intval($options['daily_token_limit']) : self::DEFAULT_DAILY_TOKEN_LIMIT;
1703 2151 $daily_used = $this->getAiDailyUsage();
1704 2152 // Check if API key and model are set
1705 - $api_key = $options['openai_api_key'] ?? '';
1706 - $model = $options['openai_model'] ?? '';
2153 + $api_key = AI_Provider::getApiKey();
2154 + $model = AI_Provider::getTextModel();
1707 2155 $api_key_valid = !empty($api_key) && !empty($model);
1708 2156
1709 2157 wp_send_json_success([
1710 2158 'daily_used' => $daily_used,
@@ -1729,10 +2177,10 @@
1729 2177 $options = get_option('king_addons_ai_options', []);
1730 2178 $daily_limit = isset($options['daily_token_limit']) ? intval($options['daily_token_limit']) : self::DEFAULT_DAILY_TOKEN_LIMIT;
1731 2179 $daily_used = $this->getAiDailyUsage();
1732 2180 // Check if API key and model are set
1733 - $api_key = $options['openai_api_key'] ?? '';
1734 - $model = $options['openai_model'] ?? '';
2181 + $api_key = AI_Provider::getApiKey();
2182 + $model = AI_Provider::getTextModel();
1735 2183 $api_key_valid = !empty($api_key) && !empty($model);
1736 2184
1737 2185 wp_send_json_success([
1738 2186 'daily_used' => $daily_used,
@@ -1843,14 +2291,14 @@
1843 2291 */
1844 2292 public function renderAiAltTextIntervalField(): void
1845 2293 {
1846 2294 $options = get_option('king_addons_ai_options', []);
1847 - $interval = isset($options['ai_alt_text_generation_interval']) ? (int) $options['ai_alt_text_generation_interval'] : 60;
2295 + $interval = isset($options['ai_alt_text_generation_interval']) ? (int) $options['ai_alt_text_generation_interval'] : 20;
1848 2296 printf(
1849 - '<input type="number" name="king_addons_ai_options[ai_alt_text_generation_interval]" value="%d" min="10" max="3600" placeholder="60" />',
2297 + '<input type="number" name="king_addons_ai_options[ai_alt_text_generation_interval]" value="%d" min="10" max="3600" placeholder="20" />',
1850 2298 $interval
1851 2299 );
1852 - echo '<p class="description">' . esc_html__('How often (in seconds) the system should process alt text generation queue. Recommended: 60 seconds to avoid OpenAI API rate limits. Lower values may cause API errors during high usage periods. Range: 10-3600 seconds.', 'king-addons') . '</p>';
2300 + echo '<p class="description">' . esc_html__('How often (in seconds) the system should process alt text generation queue. Recommended: 20 seconds. Lower values process faster; higher values reduce API load. Range: 10-3600 seconds.', 'king-addons') . '</p>';
1853 2301 }
1854 2302
1855 2303 /**
1856 2304 * Renders the image model selection dropdown field.
@@ -1890,9 +2338,9 @@
1890 2338 echo '</p>';
1891 2339 }
1892 2340
1893 2341 /**
1894 - * AJAX handler to generate images using OpenAI.
2342 + * AJAX handler to generate images using the configured AI provider.
1895 2343 *
1896 2344 * @return void
1897 2345 */
1898 2346 public function set_openai_curl_options($handle, $r, $url): void
@@ -1900,10 +2348,10 @@
1900 2348 if (!is_string($url) || $url === '') {
1901 2349 return;
1902 2350 }
1903 2351
1904 - // Apply these cURL options only to OpenAI requests to avoid affecting other outbound HTTP calls.
1905 - if (strpos($url, 'openai.com') === false) {
2352 + // Apply these cURL options only to AI provider requests to avoid affecting other outbound HTTP calls.
2353 + if (!AI_Provider::isProviderUrl($url)) {
1906 2354 return;
1907 2355 }
1908 2356
1909 2357 // Increase connect timeout to 60s, and total timeout to 5m.
@@ -1924,132 +2372,179 @@
1924 2372 $prompt = isset($_POST['prompt']) ? sanitize_textarea_field(wp_unslash($_POST['prompt'])) : '';
1925 2373 $quality = isset($_POST['quality']) ? sanitize_text_field(wp_unslash($_POST['quality'])) : '';
1926 2374 $size = isset($_POST['size']) ? sanitize_text_field(wp_unslash($_POST['size'])) : '';
1927 2375 // Model from frontend selector
1928 - $model = isset($_POST['model']) ? sanitize_text_field(wp_unslash($_POST['model'])) : 'dall-e-3';
2376 + $model = isset($_POST['model']) ? sanitize_text_field(wp_unslash($_POST['model'])) : '';
2377 + if ($model === '') {
2378 + $model = AI_Provider::getImageModel();
2379 + }
1929 2380
1930 - $options = get_option('king_addons_ai_options', []);
1931 - $api_key = $options['openai_api_key'] ?? '';
2381 + $api_key = AI_Provider::getApiKey();
1932 2382 if (empty($api_key)) {
1933 - wp_send_json_error(['message' => esc_html__('OpenAI API key is not set.', 'king-addons')], 400);
2383 + wp_send_json_error([
2384 + 'message' => sprintf(
2385 + /* translators: %s: provider name */
2386 + esc_html__('%s API key is not set.', 'king-addons'),
2387 + AI_Provider::getLabel()
2388 + )
2389 + ], 400);
1934 2390 }
1935 2391 if (empty($prompt)) {
1936 2392 wp_send_json_error(['message' => esc_html__('Please provide an image prompt.', 'king-addons')], 400);
1937 2393 }
1938 2394
1939 - // Build request body based on selected model
2395 + // Build the request body. OpenAI's image models take vendor specific
2396 + // quality/size/background options; OpenRouter fans out to many vendors
2397 + // that do not share that vocabulary, so only portable fields are sent.
1940 2398 $body = [
1941 2399 'model' => $model,
1942 2400 'prompt' => $prompt,
1943 - 'size' => $size,
1944 2401 ];
1945 - if ($model === 'dall-e-3') {
1946 - // DALL·E 3 parameters
2402 +
2403 + if (AI_Provider::isOpenRouter()) {
1947 2404 $body['n'] = 1;
1948 - $body['quality'] = ($quality === 'hd') ? 'hd' : 'standard';
1949 - } elseif ($model === 'gpt-image-1') {
1950 - // GPT Image 1 parameters
1951 - // Only include background when transparent is requested
1952 - if (!empty($_POST['background']) && 'transparent' === sanitize_text_field(wp_unslash($_POST['background']))) {
1953 - $body['background'] = 'transparent';
2405 + } else {
2406 + $body['size'] = $size;
2407 +
2408 + if ($model === 'dall-e-3') {
2409 + // DALL·E 3 parameters
2410 + $body['n'] = 1;
2411 + $body['quality'] = ($quality === 'hd') ? 'hd' : 'standard';
2412 + } elseif ($model === 'gpt-image-1') {
2413 + // GPT Image 1 parameters
2414 + // Only include background when transparent is requested
2415 + if (!empty($_POST['background']) && 'transparent' === sanitize_text_field(wp_unslash($_POST['background']))) {
2416 + $body['background'] = 'transparent';
2417 + }
2418 + $body['quality'] = in_array($quality, ['low', 'medium', 'high', 'auto'], true)
2419 + ? $quality
2420 + : 'auto';
1954 2421 }
1955 - $body['quality'] = in_array($quality, ['low', 'medium', 'high', 'auto'], true)
1956 - ? $quality
1957 - : 'auto';
1958 2422 }
1959 2423
1960 2424 add_action('http_api_curl', [$this, 'set_openai_curl_options'], 10, 3);
1961 2425
1962 - // Call OpenAI Image Generations API
2426 + // Call the provider's image generation API.
1963 2427 $response = wp_remote_post(
1964 - 'https://api.openai.com/v1/images/generations',
2428 + AI_Provider::getImagesEndpoint(),
1965 2429 [
1966 - 'headers' => [
1967 - 'Authorization' => 'Bearer ' . $api_key,
1968 - 'Content-Type' => 'application/json',
1969 - ],
2430 + 'headers' => AI_Provider::getHeaders(),
1970 2431 'body' => wp_json_encode($body),
1971 2432 'timeout' => 300,
1972 2433 ]
1973 2434 );
1974 - if (is_wp_error($response)) {
1975 - wp_send_json_error(['message' => $response->get_error_message()], 500);
2435 +
2436 + $data = AI_Provider::decodeResponse($response, esc_html__('AI image generation error.', 'king-addons'));
2437 + if (is_wp_error($data)) {
2438 + wp_send_json_error(['message' => $data->get_error_message()], 500);
1976 2439 }
1977 2440
1978 - $image_url = '';
2441 + // Depending on the model, an image comes back either as a hosted URL or
2442 + // as inline base64, so both shapes are accepted from either provider.
2443 + $item = (isset($data['data'][0]) && is_array($data['data'][0])) ? $data['data'][0] : [];
2444 + $base64 = '';
2445 + $remote_url = '';
2446 + $mime = 'image/png';
1979 2447
1980 - if ($model === 'gpt-image-1') {
1981 - // Grab and decode the base64
2448 + if (!empty($item['b64_json']) && is_string($item['b64_json'])) {
2449 + $base64 = $item['b64_json'];
2450 + if (!empty($item['media_type']) && is_string($item['media_type'])) {
2451 + $mime = $item['media_type'];
2452 + }
2453 + } elseif (!empty($item['url']) && is_string($item['url'])) {
2454 + $remote_url = $item['url'];
2455 + } elseif (!empty($item['image_url']['url']) && is_string($item['image_url']['url'])) {
2456 + $remote_url = $item['image_url']['url'];
2457 + }
1982 2458
1983 - $data = json_decode(wp_remote_retrieve_body($response), true);
2459 + // Some providers hand back a data: URI in the url field.
2460 + if ($remote_url !== '' && strpos($remote_url, 'data:') === 0 && preg_match('#^data:([^;,]+);base64,(.+)$#s', $remote_url, $matches)) {
2461 + $mime = $matches[1];
2462 + $base64 = $matches[2];
2463 + $remote_url = '';
2464 + }
1984 2465
1985 - $image_base64 = $data['data'][0]['b64_json'];
2466 + if ($base64 === '' && $remote_url === '') {
2467 + wp_send_json_error([
2468 + 'message' => sprintf(
2469 + /* translators: %s: model id */
2470 + esc_html__('The model %s did not return an image. Pick an image-capable model in AI Settings.', 'king-addons'),
2471 + $model
2472 + )
2473 + ], 500);
2474 + }
1986 2475
1987 - $bytes = base64_decode($image_base64);
2476 + require_once ABSPATH . 'wp-admin/includes/image.php';
2477 + require_once ABSPATH . 'wp-admin/includes/file.php';
2478 + require_once ABSPATH . 'wp-admin/includes/media.php';
2479 +
2480 + $filename = substr(sanitize_file_name($prompt), 0, 100);
2481 + if ($filename === '') {
2482 + $filename = 'ai-image';
2483 + }
2484 +
2485 + if ($base64 !== '') {
2486 + $bytes = base64_decode($base64, true);
1988 2487 if (!$bytes) {
1989 - wp_send_json_error(['message' => 'Invalid image data from API.'], 500);
2488 + wp_send_json_error(['message' => esc_html__('Invalid image data from API.', 'king-addons')], 500);
1990 2489 }
1991 2490
2491 + $extension = 'png';
2492 + $type_map = ['image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/webp' => 'webp', 'image/gif' => 'gif'];
2493 + if (isset($type_map[$mime])) {
2494 + $extension = $type_map[$mime];
2495 + }
2496 +
1992 2497 // Create a temp file and write it
1993 - $tmp = wp_tempnam('gpt-image-1.png');
2498 + $tmp = wp_tempnam($filename . '.' . $extension);
1994 2499 if (!$tmp || !file_put_contents($tmp, $bytes)) {
1995 - wp_send_json_error(['message' => 'Failed to write temp image file.'], 500);
2500 + wp_send_json_error(['message' => esc_html__('Failed to write temp image file.', 'king-addons')], 500);
1996 2501 }
1997 2502
1998 - // Prepare for sideload
1999 - $file = [
2000 - 'name' => substr(sanitize_file_name($prompt), 0, 100) . '.png',
2001 - 'tmp_name' => $tmp,
2002 - ];
2503 + // Sideload into the Media Library
2504 + $attachment_id = media_handle_sideload(
2505 + ['name' => $filename . '.' . $extension, 'tmp_name' => $tmp],
2506 + 0,
2507 + $prompt
2508 + );
2003 2509
2004 - // Make sure these are loaded
2005 - require_once ABSPATH . 'wp-admin/includes/image.php';
2006 - require_once ABSPATH . 'wp-admin/includes/file.php';
2007 - require_once ABSPATH . 'wp-admin/includes/media.php';
2008 -
2009 - // Sideload into the Media Library
2010 - $attach_id = media_handle_sideload($file, 0, $prompt);
2011 - if (is_wp_error($attach_id)) {
2012 - wp_send_json_error(['message' => $attach_id->get_error_message()], 500);
2510 + if (is_wp_error($attachment_id)) {
2511 + @unlink($tmp);
2512 + wp_send_json_error(['message' => $attachment_id->get_error_message()], 500);
2013 2513 }
2014 -
2015 - $url = wp_get_attachment_url($attach_id);
2016 - wp_send_json_success(['attachment_id' => $attach_id, 'url' => $url]);
2017 2514 } else {
2018 -
2019 -
2020 - $code = wp_remote_retrieve_response_code($response);
2021 - $data = json_decode(wp_remote_retrieve_body($response), true);
2022 - if ($code !== 200 || empty($data['data'][0]['url'])) {
2023 - $error_msg = $data['error']['message'] ?? esc_html__('AI image generation error.', 'king-addons');
2024 - wp_send_json_error(['message' => $error_msg], 500);
2025 - }
2026 -
2027 - // Sideload image into media library
2028 - require_once ABSPATH . 'wp-admin/includes/image.php';
2029 - require_once ABSPATH . 'wp-admin/includes/file.php';
2030 - require_once ABSPATH . 'wp-admin/includes/media.php';
2031 -
2032 - if ($model === 'dall-e-3') {
2033 - $image_url = esc_url_raw($data['data'][0]['url']);
2034 - }
2035 -
2036 - $attachment_id = media_sideload_image($image_url, 0, $prompt, 'id');
2037 -
2515 + $attachment_id = media_sideload_image(esc_url_raw($remote_url), 0, $prompt, 'id');
2038 2516 if (is_wp_error($attachment_id)) {
2039 2517 wp_send_json_error(['message' => $attachment_id->get_error_message()], 500);
2040 2518 }
2041 - $attachment_url = wp_get_attachment_url($attachment_id);
2519 + }
2042 2520
2043 - // Respond with attachment details
2044 - wp_send_json_success([
2045 - 'attachment_id' => $attachment_id,
2046 - 'url' => $attachment_url,
2047 - ]);
2048 - }
2521 + // Respond with attachment details
2522 + wp_send_json_success([
2523 + 'attachment_id' => $attachment_id,
2524 + 'url' => wp_get_attachment_url($attachment_id),
2525 + ]);
2049 2526 }
2050 2527
2051 2528 /**
2529 + * Renders the global content language field.
2530 + *
2531 + * @return void
2532 + */
2533 + public function renderAiContentLanguageField(): void
2534 + {
2535 + $options = get_option('king_addons_ai_options', []);
2536 + $enabled = !empty($options['content_language_custom_enable']);
2537 + $custom_lang = $options['content_language_custom'] ?? '';
2538 + echo '<label><input type="checkbox" name="king_addons_ai_options[content_language_custom_enable]" value="1" ' . checked($enabled, true, false) . ' id="ka-content-lang-enable-checkbox" /> ' . esc_html__('Generate content in a non-English language', 'king-addons') . '</label>';
2539 + echo '<div id="ka-content-lang-custom-wrap"' . ($enabled ? '' : ' hidden') . ' style="margin-top:8px;">';
2540 + echo '<input type="text" name="king_addons_ai_options[content_language_custom]" value="' . esc_attr($custom_lang) . '" placeholder="' . esc_attr__('language name', 'king-addons') . '" class="regular-text" id="ka-content-lang-custom-input" />';
2541 + echo '<p class="description">' . esc_html__('Applies to all AI-generated content: alt text, tags, blog posts. Type your language name, for example: Spanish, French, German, Polish, Italian, Russian, Hindi, Arabic, Portuguese, etc.', 'king-addons') . '</p>';
2542 + echo '</div>';
2543 + echo '<script>document.getElementById("ka-content-lang-enable-checkbox").addEventListener("change",function(){var w=document.getElementById("ka-content-lang-custom-wrap");if(this.checked){w.removeAttribute("hidden");}else{w.setAttribute("hidden","");}});</script>';
2544 + }
2545 +
2546 + /**
2052 2547 * Renders the Image Detail Level dropdown for Alt Text Settings.
2053 2548 *
2054 2549 * @return void
2055 2550 */
@@ -2092,9 +2587,9 @@
2092 2587 echo '<p class="description">' . esc_html__('When enabled, adds an AI Page Translator button to the Elementor editor top toolbar that allows you to translate entire pages with one click. Automatically detects and translates all text content in widgets including advanced repeater fields.', 'king-addons') . '</p>';
2093 2588 }
2094 2589
2095 2590 /**
2096 - * AJAX handler to translate text using OpenAI.
2591 + * AJAX handler to translate text using the configured AI provider.
2097 2592 *
2098 2593 * @return void
2099 2594 */
2100 2595 public function handleAiTranslateText(): void
@@ -2108,10 +2603,10 @@
2108 2603 $from_lang = isset($_POST['from_lang']) ? sanitize_text_field(wp_unslash($_POST['from_lang'])) : 'auto';
2109 2604 $to_lang = isset($_POST['to_lang']) ? sanitize_text_field(wp_unslash($_POST['to_lang'])) : 'en';
2110 2605
2111 2606 $options = get_option('king_addons_ai_options', []);
2112 - $api_key = $options['openai_api_key'] ?? '';
2113 - $model = $options['openai_model'] ?? '';
2607 + $api_key = AI_Provider::getApiKey();
2608 + $model = AI_Provider::getTextModel();
2114 2609
2115 2610 if (empty($api_key) || empty($model)) {
2116 2611 wp_send_json_error(['message' => esc_html__('API key or model not set.', 'king-addons')], 400);
2117 2612 }
@@ -2125,9 +2620,13 @@
2125 2620 $current_usage = $this->getAiDailyUsage();
2126 2621
2127 2622 if ($daily_limit > 0 && $current_usage >= $daily_limit) {
2128 2623 wp_send_json_error([
2129 - 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons')
2624 + 'message' => esc_html__('Daily token limit reached. Please try again tomorrow or increase the limit in AI Settings.', 'king-addons'),
2625 + // This is the plugin's own cap, not the provider's, so the
2626 + // client must not retry it or blame the AI provider.
2627 + 'code' => 'local_limit',
2628 + 'retryable' => false,
2130 2629 ], 429);
2131 2630 }
2132 2631
2133 2632 // Prepare translation prompt
@@ -2170,39 +2669,36 @@
2170 2669 ['role' => 'system', 'content' => $system_message],
2171 2670 ['role' => 'user', 'content' => $user_message]
2172 2671 ];
2173 2672
2174 - $response = wp_remote_post(
2175 - 'https://api.openai.com/v1/chat/completions',
2176 - [
2177 - 'headers' => [
2178 - 'Authorization' => 'Bearer ' . $api_key,
2179 - 'Content-Type' => 'application/json',
2180 - ],
2181 - 'body' => wp_json_encode([
2182 - 'model' => $model,
2183 - 'messages' => $messages,
2184 - 'max_tokens' => 1000,
2185 - 'temperature' => 0.3, // Lower temperature for more consistent translations
2186 - ]),
2187 - 'timeout' => 30,
2188 - ]
2673 + $data = AI_Provider::decodeResponse(
2674 + wp_remote_post(
2675 + AI_Provider::getChatEndpoint(),
2676 + [
2677 + 'headers' => AI_Provider::getHeaders(),
2678 + 'body' => wp_json_encode(AI_Provider::prepareChatPayload([
2679 + 'model' => $model,
2680 + 'messages' => $messages,
2681 + 'max_tokens' => 1000,
2682 + 'temperature' => 0.3, // Lower temperature for more consistent translations
2683 + ])),
2684 + 'timeout' => 60, // Free and reasoning models are routinely slower than 30s.
2685 + ]
2686 + ),
2687 + esc_html__('AI translation error.', 'king-addons')
2189 2688 );
2190 2689
2191 - if (is_wp_error($response)) {
2192 - wp_send_json_error(['message' => $response->get_error_message()], 500);
2690 + // The translator runs hundreds of these back to back, so it needs to
2691 + // know whether a failure is worth retrying or is a dead end.
2692 + if (is_wp_error($data)) {
2693 + $this->sendAiProviderError($data);
2193 2694 }
2194 2695
2195 - $code = wp_remote_retrieve_response_code($response);
2196 - $data = json_decode(wp_remote_retrieve_body($response), true);
2197 -
2198 - if ($code !== 200 || empty($data['choices'][0]['message']['content'])) {
2199 - $error_msg = $data['error']['message'] ?? esc_html__('AI translation error.', 'king-addons');
2200 - wp_send_json_error(['message' => $error_msg], 500);
2696 + $translated_text = AI_Provider::extractMessageContent($data);
2697 + if (is_wp_error($translated_text)) {
2698 + $this->sendAiProviderError($translated_text);
2201 2699 }
2202 2700
2203 - $translated_text = trim($data['choices'][0]['message']['content']);
2204 -
2205 2701 // Update token usage statistics if present in the response
2206 2702 if (isset($data['usage']['total_tokens'])) {
2207 2703 $this->incrementAiDailyUsage(intval($data['usage']['total_tokens']));
2208 2704 }
@@ -2214,8 +2710,34 @@
2214 2710 'daily_used' => $this->getAiDailyUsage(),
2215 2711 'daily_limit' => $daily_limit,
2216 2712 ]
2217 2713 ]);
2714 + }
2715 +
2716 + /**
2717 + * Ends an AJAX request with a classified provider error.
2718 + *
2719 + * Sends the closest meaningful HTTP status instead of a blanket 500, plus a
2720 + * machine readable code and a retryable flag so the caller can back off and
2721 + * retry transient failures rather than aborting a long run.
2722 + *
2723 + * @param \WP_Error $error Error from the provider layer.
2724 + * @return void
2725 + */
2726 + private function sendAiProviderError(\WP_Error $error): void
2727 + {
2728 + $classified = AI_Provider::classifyError($error);
2729 +
2730 + wp_send_json_error(
2731 + [
2732 + 'message' => $classified['message'],
2733 + 'code' => $classified['code'],
2734 + 'retryable' => $classified['retryable'],
2735 + 'provider' => AI_Provider::getProvider(),
2736 + 'provider_label' => AI_Provider::getLabel(),
2737 + ],
2738 + AI_Provider::getResponseStatus($classified)
2739 + );
2218 2740 }
2219 2741
2220 2742 /**
2221 2743 * Get language name by code