PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.1.1
MxChat – AI Chatbot & Content Generation for WordPress v3.1.1
3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 2.0.3 2.0.4 2.0.5 All 153 releases
← All changes | includes/class-mxchat-content-generator.php +142 -492 3.2.213.1.1 View file →
@@ -32,10 +32,8 @@
32 32 add_action('wp_ajax_mxchat_seo_analyze', array($this, 'handle_seo_analyze'));
33 33 add_action('wp_ajax_mxchat_seo_analyze_batch', array($this, 'handle_seo_analyze_batch'));
34 34 add_action('wp_ajax_mxchat_seo_suggest', array($this, 'handle_seo_suggest'));
35 35 add_action('wp_ajax_mxchat_seo_list_posts', array($this, 'handle_seo_list_posts'));
36 - add_action('wp_ajax_mxchat_get_default_prompt', array($this, 'handle_get_default_prompt'));
37 - add_action('wp_ajax_mxchat_save_custom_prompt', array($this, 'handle_save_custom_prompt'));
38 36
39 37 // Background generation via loopback (nopriv because loopback doesn't carry cookies — auth via secret token)
40 38 add_action('wp_ajax_nopriv_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
41 39 add_action('wp_ajax_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
@@ -103,10 +101,12 @@
103 101 // ─── Frontend CSS Injection ──────────────────────────────────────
104 102
105 103 /**
106 104 * Inject generated CSS into <head> on the frontend.
107 - * Legacy fallback for posts created before CSS was embedded in post_content.
108 - * New posts (with mxchat-css CSS comment marker) skip this entirely.
105 + * Fires on wp_head for any singular post/page that was created
106 + * by the content generator. CSS is stored in post meta to avoid
107 + * issues with wp_kses, wpautop, and wptexturize mangling styles
108 + * stored in post_content.
109 109 */
110 110 public function inject_generated_css() {
111 111 if (!is_singular()) {
112 112 return;
@@ -121,23 +121,54 @@
121 121 if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
122 122 return;
123 123 }
124 124
125 - // New-format posts have CSS embedded in post_content — skip injection
126 - $post = get_post($post_id);
127 - if ($post && strpos($post->post_content, '/* mxchat-css */') !== false) {
128 - return;
129 - }
130 -
131 - // Legacy fallback: inject via wp_head as before
132 125 $ai_css = get_post_meta($post_id, '_mxchat_content_css', true);
133 126 $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1';
134 127 $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1';
135 128
129 + // Output the combined CSS
136 130 echo $this->get_generated_css($fullwidth, $ai_css);
137 131
132 + // Hide the WordPress post/page title if configured.
133 + // Covers: generic WP, Astra, GeneratePress, Kadence, OceanWP, Neve,
134 + // Hello Elementor, Bricks, Divi, Blocksy, and common starter themes.
138 135 if ($hide_title) {
139 - echo '<style>' . $this->get_title_hide_css() . '</style>' . "\n";
136 + echo '<style>
137 +.entry-title,
138 +.page-title,
139 +.post-title,
140 +.wp-block-post-title,
141 +/* Astra */
142 +.ast-title-with-post-meta-wrapper,
143 +.ast-the-title,
144 +/* GeneratePress */
145 +.generate-page-header .page-hero,
146 +.entry-header .entry-title,
147 +/* Kadence */
148 +.entry-hero .entry-title,
149 +.kadence-page-title,
150 +.wp-site-blocks .entry-title,
151 +/* OceanWP */
152 +.ocean-single-post-header,
153 +.page-header,
154 +/* Neve */
155 +.nv-page-title-wrap,
156 +.nv-post-title,
157 +/* Hello Elementor */
158 +.elementor-page-title,
159 +/* Divi */
160 +.et_pb_title_container .entry-title,
161 +/* Bricks */
162 +.brxe-post-title,
163 +/* Blocksy */
164 +[data-hero] .page-title,
165 +.hero-section .page-title,
166 +/* Generic header containers */
167 +.entry-header {
168 + display: none !important;
169 +}
170 +</style>' . "\n";
140 171 }
141 172 }
142 173
143 174 /**
@@ -289,10 +320,8 @@
289 320 $post_status = sanitize_text_field($_POST['post_status'] ?? 'draft');
290 321 $schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
291 322 $layout = sanitize_text_field($_POST['layout'] ?? 'fullwidth');
292 323 $title_display = sanitize_text_field($_POST['title_display'] ?? 'hide');
293 - $template_mode = sanitize_text_field($_POST['template_mode'] ?? 'off');
294 - $custom_system_prompt = wp_unslash($_POST['custom_system_prompt'] ?? '');
295 324
296 325 if (empty($prompt)) {
297 326 wp_send_json_error(array('message' => __('Please enter a prompt.', 'mxchat')));
298 327 }
@@ -320,10 +349,8 @@
320 349 'post_status' => $post_status,
321 350 'schedule_date' => $schedule_date,
322 351 'layout' => $layout,
323 352 'title_display' => $title_display,
324 - 'template_mode' => $template_mode,
325 - 'custom_system_prompt' => $custom_system_prompt,
326 353 );
327 354 set_transient($progress_key . '_params', $params, 600);
328 355
329 356 // Send JSON response to the browser immediately, then continue
@@ -423,10 +450,8 @@
423 450 $post_status = $params['post_status'];
424 451 $schedule_date = $params['schedule_date'];
425 452 $layout = $params['layout'];
426 453 $title_display = $params['title_display'];
427 - $template_mode = $params['template_mode'] ?? 'off';
428 - $custom_system_prompt = $params['custom_system_prompt'] ?? '';
429 454
430 455 $this->update_progress($progress_key, 'planning', __('Planning content structure...', 'mxchat'), 10);
431 456
432 457 // Step 1: Plan the content
@@ -443,9 +468,9 @@
443 468
444 469 $this->update_progress($progress_key, 'writing', __('Writing full content...', 'mxchat'), 60);
445 470
446 471 // Step 3: Generate full HTML content
447 - $html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt, $custom_system_prompt);
472 + $html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt);
448 473 if (is_wp_error($html_content)) {
449 474 $this->update_progress($progress_key, 'error', $html_content->get_error_message(), 0);
450 475 return;
451 476 }
@@ -452,31 +477,24 @@
452 477
453 478 $this->update_progress($progress_key, 'creating', __('Creating WordPress post...', 'mxchat'), 85);
454 479
455 480 // Step 4: Create the WordPress post/page
456 - // Extract AI CSS — saved to post meta for edit workflow, and embedded in post_content for portability
481 + // Extract AI CSS before sanitization — stored in post meta, injected via wp_head
457 482 $ai_css = $this->extract_css($html_content);
458 483 $html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $html_content);
459 484 $sanitized_html = $this->sanitize_generated_html($html_without_style);
460 485
461 - // Build self-contained <style> block (all layers) and prepend to HTML
462 - $is_fullwidth = ($layout === 'fullwidth');
463 - $is_hide_title = ($title_display === 'hide');
464 - $embedded_css = $this->build_embedded_css($ai_css, $is_fullwidth, $is_hide_title);
465 - $full_content = $embedded_css . $sanitized_html;
466 -
467 486 $post_args = array(
468 487 'post_title' => sanitize_text_field($plan['title']),
469 - 'post_content' => $full_content,
488 + 'post_content' => $sanitized_html,
470 489 'post_status' => $post_status,
471 490 'post_type' => $content_type === 'page' ? 'page' : 'post',
472 491 'meta_input' => array(
473 - '_mxchat_generated' => '1',
474 - '_mxchat_prompt' => $prompt,
475 - '_mxchat_fullwidth' => ($layout === 'fullwidth') ? '1' : '0',
476 - '_mxchat_hide_title' => ($title_display === 'hide') ? '1' : '0',
477 - '_mxchat_content_css' => $ai_css,
478 - '_mxchat_template_mode' => ($template_mode === 'on') ? '1' : '0',
492 + '_mxchat_generated' => '1',
493 + '_mxchat_prompt' => $prompt,
494 + '_mxchat_fullwidth' => ($layout === 'fullwidth') ? '1' : '0',
495 + '_mxchat_hide_title' => ($title_display === 'hide') ? '1' : '0',
496 + '_mxchat_content_css' => $ai_css,
479 497 ),
480 498 );
481 499
482 500 // Handle scheduled posts
@@ -511,14 +529,8 @@
511 529 if (!empty($image_ids)) {
512 530 update_post_meta($post_id, '_mxchat_image_ids', $image_ids);
513 531 }
514 532
515 - // Sync the inline `<img alt="...">` text from the generated body HTML
516 - // into each attachment's _wp_attachment_image_alt postmeta so RankMath,
517 - // schema markup, and the media library all see the alt text — not just
518 - // the inline body HTML. (plan-mxchat-20260510-2be2da)
519 - $this->sync_image_alts_from_content($full_content, $image_urls);
520 -
521 533 // Apply fullwidth/title settings via theme-specific post meta
522 534 $this->apply_layout_settings($post_id, $layout, $title_display);
523 535
524 536 // Step 5: Fill SEO metadata
@@ -593,18 +605,14 @@
593 605
594 606 // Build the edit prompt — send both CSS and HTML so AI can edit either
595 607 $system_prompt = "You are a content editor. You have an existing page that uses a CSS-first approach: a <style> block with mxg- prefixed classes followed by clean HTML.\n\nApply ONLY the requested change and return the complete updated output. Do not add commentary — return ONLY the updated <style> block + HTML.\n\nIMPORTANT RULES:\n- Keep the same overall structure\n- Only change what the user specifically asks for\n- Return the complete output (not just the changed part)\n- If the user asks to change the title, update the <h1> in the HTML\n- Maintain the <style> block — update CSS rules if the edit requires style changes\n- ALL class names must use the mxg- prefix\n- Do NOT add inline styles — all styling stays in the <style> block\n- Do NOT include HTML comments\n- Do NOT generate a <header>, <footer>, or <nav>";
596 608
597 - // Strip embedded boilerplate CSS from post_content (Layer 1/2 + marker)
598 - // so the AI only sees the clean AI CSS + HTML
599 - $clean_content = preg_replace('/<style>\/\* mxchat-css \*\/.*?<\/style>\s*/is', '', $current_content);
600 -
601 - // Reconstruct: prepend only the AI CSS (from meta) + clean HTML
609 + // Reconstruct the full content (CSS + HTML) for the AI to edit
602 610 $full_content_for_ai = '';
603 611 if (!empty($current_css)) {
604 612 $full_content_for_ai = "<style>\n" . $current_css . "\n</style>\n";
605 613 }
606 - $full_content_for_ai .= $clean_content;
614 + $full_content_for_ai .= $current_content;
607 615
608 616 $user_message = "Current post title: " . $current_title . "\n\nCurrent content (style block + HTML):\n" . $full_content_for_ai . "\n\nUser edit request: " . $edit_instruction;
609 617
610 618 $messages = array(
@@ -637,19 +645,13 @@
637 645 if (preg_match('/<h1[^>]*>(.*?)<\/h1>/is', $result, $title_match)) {
638 646 $new_title = wp_strip_all_tags($title_match[1]);
639 647 }
640 648
641 - // Extract CSS → meta (for edit workflow), embed full CSS in post_content (for portability)
649 + // Extract CSS → meta, sanitize HTML → post_content
642 650 $ai_css = $this->extract_css($result);
643 651 $html_without_style = preg_replace('/<style[^>]*>.*?<\/style>/is', '', $result);
644 652 $sanitized_html = $this->sanitize_generated_html($html_without_style);
645 653
646 - // Re-embed all CSS layers into post_content
647 - $fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1';
648 - $hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1';
649 - $embedded_css = $this->build_embedded_css($ai_css, $fullwidth, $hide_title);
650 - $full_content = $embedded_css . $sanitized_html;
651 -
652 654 // Clear invalid page template meta that causes "Invalid page template" errors
653 655 $current_template = get_post_meta($post_id, '_wp_page_template', true);
654 656 if (!empty($current_template) && $current_template !== 'default') {
655 657 $theme_templates = wp_get_theme()->get_page_templates(get_post($post_id));
@@ -657,16 +659,16 @@
657 659 delete_post_meta($post_id, '_wp_page_template');
658 660 }
659 661 }
660 662
661 - // Update the post content (CSS + HTML) and meta
663 + // Update the post content (HTML only)
662 664 $update_result = wp_update_post(array(
663 665 'ID' => $post_id,
664 666 'post_title' => sanitize_text_field($new_title),
665 - 'post_content' => $full_content,
667 + 'post_content' => $sanitized_html,
666 668 ), true);
667 669
668 - // Keep CSS in meta for edit workflow reconstruction
670 + // Update CSS in meta (injected via wp_head)
669 671 update_post_meta($post_id, '_mxchat_content_css', $ai_css);
670 672
671 673 if (is_wp_error($update_result)) {
672 674 wp_send_json_error(array('message' => $update_result->get_error_message()));
@@ -731,21 +733,10 @@
731 733 */
732 734 private function plan_content($prompt, $content_type) {
733 735 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
734 736
735 - // "Images per article" control (1–5, default 3). Drives how many sections the
736 - // planner marks needs_image:true. When image generation is off, force zero.
737 - $plan_options = get_option('mxchat_options', array());
738 - $images_enabled = ($plan_options['content_enable_images'] ?? 'on') === 'on';
739 - $image_count = max(1, min(5, (int) ($plan_options['content_image_count'] ?? 3)));
740 - if ($images_enabled) {
741 - $image_instruction = "- Select EXACTLY {$image_count} section(s) to illustrate: set \"needs_image\": true with a detailed \"image_prompt\" on exactly those {$image_count} section(s) and \"needs_image\": false with an empty \"image_prompt\" on every other section. Spread the illustrated sections across the article (favor the hero and the most visual content sections)";
742 - } else {
743 - $image_instruction = "- Set \"needs_image\": false and \"image_prompt\": \"\" on ALL sections (image generation is turned off for this site)";
744 - }
737 + $system_prompt = "You are a professional content strategist. The user wants to create a {$type_label}. Analyze their request and create a detailed content plan.\n\nYou MUST respond with ONLY valid JSON (no markdown, no code fences, no commentary). Use this exact structure:\n\n{\"title\": \"SEO-optimized title\", \"slug\": \"url-friendly-slug\", \"meta_description\": \"155 character meta description for SEO\", \"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"], \"links\": [{\"label\": \"Button or link text\", \"url\": \"https://example.com/page\"}], \"sections\": [{\"type\": \"hero\", \"heading\": \"Main heading\", \"subheading\": \"Supporting text\", \"needs_image\": true, \"image_prompt\": \"Detailed prompt for hero image\"}, {\"type\": \"content\", \"heading\": \"Section heading\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": true, \"image_prompt\": \"Detailed prompt for section image\"}, {\"type\": \"content\", \"heading\": \"Another section\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": false, \"image_prompt\": \"\"}, {\"type\": \"cta\", \"heading\": \"Call to action heading\", \"subheading\": \"CTA supporting text\", \"needs_image\": false, \"image_prompt\": \"\"}]}\n\nGuidelines:\n- Create 5-8 sections for blog posts, 4-6 for landing pages\n- Include 2-4 sections that need images\n- Image prompts should be detailed, descriptive, and suitable for AI image generation\n- Image prompts should describe photorealistic or illustrative images relevant to the content\n- Keywords should be relevant long-tail SEO keywords\n- Title should be compelling and SEO-friendly\n- IMPORTANT: If the user specifies any URLs or links (for buttons, CTAs, navigation, etc.), you MUST capture every one of them in the \"links\" array with its label and exact URL. If no URLs are mentioned, use an empty array [].";
745 738
746 - $system_prompt = "You are a professional content strategist. The user wants to create a {$type_label}. Analyze their request and create a detailed content plan.\n\nYou MUST respond with ONLY valid JSON (no markdown, no code fences, no commentary). Use this exact structure:\n\n{\"title\": \"SEO-optimized title\", \"slug\": \"url-friendly-slug\", \"meta_description\": \"155 character meta description for SEO\", \"keywords\": [\"keyword1\", \"keyword2\", \"keyword3\", \"keyword4\", \"keyword5\"], \"links\": [{\"label\": \"Button or link text\", \"url\": \"https://example.com/page\"}], \"sections\": [{\"type\": \"hero\", \"heading\": \"Main heading\", \"subheading\": \"Supporting text\", \"needs_image\": true, \"image_prompt\": \"Detailed prompt for hero image\"}, {\"type\": \"content\", \"heading\": \"Section heading\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": true, \"image_prompt\": \"Detailed prompt for section image\"}, {\"type\": \"content\", \"heading\": \"Another section\", \"key_points\": [\"point 1\", \"point 2\"], \"needs_image\": false, \"image_prompt\": \"\"}, {\"type\": \"cta\", \"heading\": \"Call to action heading\", \"subheading\": \"CTA supporting text\", \"needs_image\": false, \"image_prompt\": \"\"}]}\n\nGuidelines:\n- Create 5-9 sections for blog posts, 7-11 for landing pages — add more if the user's request warrants extensive coverage\n- For landing pages, draw from sections like: hero, problem/solution, features, benefits, how-it-works, use cases, testimonials/social proof, pricing or comparison, FAQ, and final CTA — pick whichever fit the request\n- Each content section should include 3-5 substantive key_points (full descriptive sentences, not one-word labels) so the HTML generator has enough material to write meaningful copy\n{$image_instruction}\n- Image prompts should be detailed, descriptive, and suitable for AI image generation\n- Image prompts should describe photorealistic or illustrative images relevant to the content\n- Keywords should be relevant long-tail SEO keywords\n- Title should be compelling and SEO-friendly\n- IMPORTANT: If the user specifies any URLs or links (for buttons, CTAs, navigation, etc.), you MUST capture every one of them in the \"links\" array with its label and exact URL. If no URLs are mentioned, use an empty array [].";
747 -
748 739 $messages = array(
749 740 array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"),
750 741 );
751 742
@@ -799,15 +790,8 @@
799 790 );
800 791 }
801 792 }
802 793
803 - // Safety cap: never generate more than the user-configured "Images per article"
804 - // count (1–5, default 3), even if the planner over-marked needs_image sections.
805 - $image_count = max(1, min(5, (int) ($options['content_image_count'] ?? 3)));
806 - if (count($image_sections) > $image_count) {
807 - $image_sections = array_slice($image_sections, 0, $image_count);
808 - }
809 -
810 794 if (empty($image_sections)) {
811 795 return array();
812 796 }
813 797
@@ -996,9 +980,9 @@
996 980 'model' => 'gpt-image-1.5',
997 981 'prompt' => $prompt,
998 982 'n' => 1,
999 983 'size' => '1536x1024',
1000 - 'quality' => $options['content_image_quality'] ?? 'auto',
984 + 'quality' => 'medium',
1001 985 'output_format' => 'png',
1002 986 )),
1003 987 );
1004 988 }
@@ -1120,9 +1104,9 @@
1120 1104 'model' => 'gpt-image-1.5',
1121 1105 'prompt' => $prompt,
1122 1106 'n' => 1,
1123 1107 'size' => '1536x1024',
1124 - 'quality' => $options['content_image_quality'] ?? 'auto',
1108 + 'quality' => 'medium',
1125 1109 'output_format' => 'png',
1126 1110 );
1127 1111
1128 1112 $response = wp_remote_post('https://api.openai.com/v1/images/generations', array(
@@ -1360,9 +1344,9 @@
1360 1344
1361 1345 /**
1362 1346 * Step 3: Generate the full HTML content
1363 1347 */
1364 - private function generate_html_content($plan, $image_urls, $content_type, $original_prompt = '', $custom_system_prompt = '') {
1348 + private function generate_html_content($plan, $image_urls, $content_type, $original_prompt = '') {
1365 1349 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
1366 1350 $has_images = !empty($image_urls);
1367 1351
1368 1352 // Build image URL map by section index
@@ -1401,17 +1385,9 @@
1401 1385 }
1402 1386 $image_reference .= "=== END IMAGE URLS — Do NOT use any other image URLs ===\n";
1403 1387 }
1404 1388
1405 - // Check for custom prompt: passed directly, or saved in DB
1406 - if (empty($custom_system_prompt)) {
1407 - $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1408 - $custom_system_prompt = get_option($option_key, '');
1409 - }
1410 -
1411 - if (!empty($custom_system_prompt)) {
1412 - $system_prompt = $custom_system_prompt;
1413 - } elseif ($content_type === 'page') {
1389 + if ($content_type === 'page') {
1414 1390 $system_prompt = $this->get_landing_page_prompt($has_images);
1415 1391 } else {
1416 1392 $system_prompt = $this->get_blog_post_prompt($has_images);
1417 1393 }
@@ -1416,9 +1392,9 @@
1416 1392 $system_prompt = $this->get_blog_post_prompt($has_images);
1417 1393 }
1418 1394
1419 1395 // Allow add-ons to modify the system prompt (e.g. internal linking instructions)
1420 - $system_prompt = apply_filters('mxchat_content_system_prompt', $system_prompt, $plan, $content_type, $template_mode);
1396 + $system_prompt = apply_filters('mxchat_content_system_prompt', $system_prompt, $plan, $content_type);
1421 1397
1422 1398 // Include the original user prompt so the AI can see any specific URLs, links, or details the user mentioned
1423 1399 $original_context = '';
1424 1400 if (!empty($original_prompt)) {
@@ -1501,58 +1477,8 @@
1501 1477 return $html;
1502 1478 }
1503 1479
1504 1480 /**
1505 - * AJAX handler: return the default system prompt for the given content type.
1506 - */
1507 - public function handle_get_default_prompt() {
1508 - check_ajax_referer('mxchat_content_nonce', 'nonce');
1509 -
1510 - if (!current_user_can('manage_options')) {
1511 - wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
1512 - }
1513 -
1514 - $content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
1515 -
1516 - if ($content_type === 'page') {
1517 - $default = $this->get_landing_page_prompt(true);
1518 - } else {
1519 - $default = $this->get_blog_post_prompt(true);
1520 - }
1521 -
1522 - $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1523 - $saved = get_option($option_key, '');
1524 -
1525 - wp_send_json_success(array(
1526 - 'default_prompt' => $default,
1527 - 'saved_prompt' => $saved,
1528 - ));
1529 - }
1530 -
1531 - /**
1532 - * AJAX handler: save or reset a custom system prompt for a content type.
1533 - */
1534 - public function handle_save_custom_prompt() {
1535 - check_ajax_referer('mxchat_content_nonce', 'nonce');
1536 -
1537 - if (!current_user_can('manage_options')) {
1538 - wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
1539 - }
1540 -
1541 - $content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
1542 - $custom_prompt = wp_unslash($_POST['custom_prompt'] ?? '');
1543 - $option_key = 'mxchat_custom_prompt_' . ($content_type === 'page' ? 'page' : 'post');
1544 -
1545 - if (empty($custom_prompt)) {
1546 - delete_option($option_key);
1547 - } else {
1548 - update_option($option_key, $custom_prompt, false);
1549 - }
1550 -
1551 - wp_send_json_success();
1552 - }
1553 -
1554 - /**
1555 1481 * System prompt optimized for landing page generation (CSS-first approach).
1556 1482 *
1557 1483 * The AI outputs a <style> block with ALL CSS using mxg- prefixed classes,
1558 1484 * followed by clean semantic HTML referencing those classes. No inline styles.
@@ -1614,10 +1540,8 @@
1614 1540 - Alternate light (#ffffff) and subtle gray (#f8fafc) backgrounds
1615 1541 - Two-column layouts alternating content left/right
1616 1542 - Card grids for features/benefits
1617 1543 - Each section gets its own mxg- class for unique styling
1618 -- Write substantive copy in every section — expand each plan key_point into a full sentence or short paragraph (roughly 80-180 words per content section). Do NOT condense the plan into one-line bullets
1619 -- Vary the structure across sections: paragraphs, feature card grids, numbered steps, stat callouts, testimonial quotes, comparison tables, or FAQ blocks where they fit the content
1620 1544
1621 1545 TYPOGRAPHY:
1622 1546 - Hero heading: 3rem desktop, scales down in your media queries
1623 1547 - Section headings: 2.25rem desktop
@@ -1747,65 +1671,28 @@
1747 1671 }
1748 1672 return trim($css);
1749 1673 }
1750 1674
1751 - /**
1752 - * Build a self-contained <style> block to embed in post_content.
1753 - * Combines all three CSS layers so the page renders correctly
1754 - * even if the plugin is deactivated.
1755 - */
1756 - private function build_embedded_css($ai_css, $fullwidth = false, $hide_title = false) {
1757 - $css = "<style>/* mxchat-css */\n";
1758 -
1759 - // Layer 1: Fullwidth theme resets
1760 - if ($fullwidth) {
1761 - $css .= $this->get_fullwidth_reset_css();
1762 - }
1763 -
1764 - // Layer 2: mxg- isolation + responsive overrides
1765 - $css .= $this->get_isolation_css();
1766 -
1767 - // Layer 3: AI-generated CSS
1768 - // Collapse blank lines to prevent wpautop from injecting <p> tags
1769 - // inside the style block when the_content filter runs.
1770 - if (!empty($ai_css)) {
1771 - $clean_css = preg_replace('/\n\s*\n/', "\n", $ai_css);
1772 - $css .= "\n/* MxChat — AI Generated Styles */\n" . $clean_css . "\n";
1773 - }
1774 -
1775 - // Title hiding
1776 - if ($hide_title) {
1777 - $css .= $this->get_title_hide_css();
1778 - }
1779 -
1780 - $css .= "</style>\n";
1781 - return $css;
1782 - }
1783 -
1784 1675 // ─── Layout / Fullwidth Settings ──────────────────────────────────
1785 1676
1786 1677 /**
1787 - * Build a single <style> block for legacy posts (CSS not yet embedded in post_content).
1788 - * Used by inject_generated_css() as a backwards-compatibility fallback.
1678 + * Build a single <style> block for generated content.
1679 + *
1680 + * Merges three layers:
1681 + * 1. Theme padding reset (when fullwidth is enabled)
1682 + * 2. Our mxg- responsive overrides (always)
1683 + * 3. AI-generated CSS extracted from the content (page-specific design)
1684 + *
1685 + * @param bool $fullwidth Whether to include theme padding reset rules.
1686 + * @param string $ai_css Raw CSS extracted from the AI output (no <style> tags).
1687 + * @return string Complete <style>...</style> block ready to prepend to post content.
1789 1688 */
1790 1689 private function get_generated_css($fullwidth = true, $ai_css = '') {
1791 1690 $css = '<style>' . "\n";
1691 +
1692 + // Layer 1: Theme & page builder padding reset — only when fullwidth is selected
1792 1693 if ($fullwidth) {
1793 - $css .= $this->get_fullwidth_reset_css();
1794 - }
1795 - $css .= $this->get_isolation_css();
1796 - if (!empty($ai_css)) {
1797 - $css .= "\n/* MxChat — AI Generated Styles */\n" . $ai_css . "\n";
1798 - }
1799 - $css .= '</style>';
1800 - return $css;
1801 - }
1802 -
1803 - /**
1804 - * Layer 1: Theme & page builder fullwidth padding resets.
1805 - */
1806 - private function get_fullwidth_reset_css() {
1807 - return '/* MxChat — Fullwidth Theme Reset */
1694 + $css .= '/* MxChat — Fullwidth Theme Reset */
1808 1695 /* Generic WordPress themes */
1809 1696 .entry-content-wrap,
1810 1697 .entry-content,
1811 1698 .post-inner .entry-content,
@@ -1816,27 +1703,13 @@
1816 1703 .type-post .entry-content,
1817 1704 .type-page .entry-content,
1818 1705 .page .entry-content,
1819 1706 .single .entry-content {
1820 - padding: 0 !important;
1707 + padding-left: 0 !important;
1708 + padding-right: 0 !important;
1821 1709 max-width: 100% !important;
1822 1710 width: 100% !important;
1823 1711 }
1824 -/* Outer wrappers that themes use to add spacing */
1825 -.site-main > article,
1826 -.content-area,
1827 -.site-content,
1828 -#content,
1829 -#primary,
1830 -.hentry,
1831 -.post,
1832 -.page .post,
1833 -.single .post {
1834 - padding: 0 !important;
1835 - margin-left: 0 !important;
1836 - margin-right: 0 !important;
1837 - max-width: 100% !important;
1838 -}
1839 1712 /* Astra */
1840 1713 .ast-container .entry-content,
1841 1714 .site-content .ast-container,
1842 1715 .ast-separate-container .ast-article-single,
@@ -1854,9 +1727,10 @@
1854 1727 /* GeneratePress */
1855 1728 .inside-article .entry-content,
1856 1729 .generate-columns-container,
1857 1730 .inside-article {
1858 - padding: 0 !important;
1731 + padding-left: 0 !important;
1732 + padding-right: 0 !important;
1859 1733 max-width: 100% !important;
1860 1734 width: 100% !important;
1861 1735 }
1862 1736 /* Kadence */
@@ -1862,9 +1736,10 @@
1862 1736 /* Kadence */
1863 1737 .kb-row-layout-wrap,
1864 1738 .entry-content-wrap,
1865 1739 .content-container.site-container {
1866 - padding: 0 !important;
1740 + padding-left: 0 !important;
1741 + padding-right: 0 !important;
1867 1742 max-width: 100% !important;
1868 1743 width: 100% !important;
1869 1744 }
1870 1745 .content-style-unboxed .entry:not(.loop-entry),
@@ -1876,9 +1751,10 @@
1876 1751 }
1877 1752 /* OceanWP */
1878 1753 .ocean-content .entry,
1879 1754 #content-wrap .container {
1880 - padding: 0 !important;
1755 + padding-left: 0 !important;
1756 + padding-right: 0 !important;
1881 1757 max-width: 100% !important;
1882 1758 width: 100% !important;
1883 1759 }
1884 1760 /* Neve */
@@ -1883,9 +1759,10 @@
1883 1759 }
1884 1760 /* Neve */
1885 1761 .nv-single-post-wrap .entry-content,
1886 1762 .nv-content-wrap .entry-content {
1887 - padding: 0 !important;
1763 + padding-left: 0 !important;
1764 + padding-right: 0 !important;
1888 1765 max-width: 100% !important;
1889 1766 width: 100% !important;
1890 1767 }
1891 1768 /* Hello Elementor / Elementor default theme */
@@ -1891,9 +1768,10 @@
1891 1768 /* Hello Elementor / Elementor default theme */
1892 1769 .site-main .elementor-section-wrap,
1893 1770 .elementor-page .page-content .entry-content,
1894 1771 .elementor-default .entry-content {
1895 - padding: 0 !important;
1772 + padding-left: 0 !important;
1773 + padding-right: 0 !important;
1896 1774 max-width: 100% !important;
1897 1775 width: 100% !important;
1898 1776 }
1899 1777 /* Bricks Builder */
@@ -1899,9 +1777,10 @@
1899 1777 /* Bricks Builder */
1900 1778 .brxe-post-content .entry-content,
1901 1779 .bricks-layout-wrapper .entry-content,
1902 1780 .brxe-container .entry-content {
1903 - padding: 0 !important;
1781 + padding-left: 0 !important;
1782 + padding-right: 0 !important;
1904 1783 max-width: 100% !important;
1905 1784 width: 100% !important;
1906 1785 }
1907 1786 /* Divi */
@@ -1907,9 +1786,10 @@
1907 1786 /* Divi */
1908 1787 .et_pb_post .entry-content,
1909 1788 #main-content .container .entry-content,
1910 1789 .et_full_width_page .entry-content {
1911 - padding: 0 !important;
1790 + padding-left: 0 !important;
1791 + padding-right: 0 !important;
1912 1792 max-width: 100% !important;
1913 1793 width: 100% !important;
1914 1794 }
1915 1795 /* Beaver Builder */
@@ -1914,9 +1794,10 @@
1914 1794 }
1915 1795 /* Beaver Builder */
1916 1796 .fl-post-content .entry-content,
1917 1797 .fl-content-full .entry-content {
1918 - padding: 0 !important;
1798 + padding-left: 0 !important;
1799 + padding-right: 0 !important;
1919 1800 max-width: 100% !important;
1920 1801 width: 100% !important;
1921 1802 }
1922 1803 /* Blocksy */
@@ -1921,9 +1802,10 @@
1921 1802 }
1922 1803 /* Blocksy */
1923 1804 .entry-content[data-source],
1924 1805 .site-main > article > .entry-content {
1925 - padding: 0 !important;
1806 + padding-left: 0 !important;
1807 + padding-right: 0 !important;
1926 1808 max-width: 100% !important;
1927 1809 width: 100% !important;
1928 1810 }
1929 1811 /* Spectra / starter templates */
@@ -1928,37 +1810,21 @@
1928 1810 }
1929 1811 /* Spectra / starter templates */
1930 1812 .uagb-body-wrapper .entry-content,
1931 1813 .starter-template-content .entry-content {
1932 - padding: 0 !important;
1933 - max-width: 100% !important;
1934 - width: 100% !important;
1935 -}
1936 -/* WordPress Block Themes (Twenty Twenty-Two → Twenty Twenty-Five)
1937 - These use theme.json layout constraints rather than classic .entry-content,
1938 - so the selectors above do not apply — neutralize them here. */
1939 -.wp-site-blocks,
1940 -.wp-block-post-content,
1941 -.wp-block-group.has-global-padding,
1942 -.has-global-padding {
1943 1814 padding-left: 0 !important;
1944 1815 padding-right: 0 !important;
1945 1816 max-width: 100% !important;
1817 + width: 100% !important;
1946 1818 }
1947 -.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)),
1948 -.wp-block-post-content > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
1949 - max-width: 100% !important;
1950 - margin-left: 0 !important;
1951 - margin-right: 0 !important;
1952 -}
1953 1819 ';
1954 - }
1820 + }
1955 1821
1956 - /**
1957 - * Layer 2: CSS isolation + responsive overrides for mxg- classes.
1958 - */
1959 - private function get_isolation_css() {
1960 - return '/* MxChat — CSS Isolation & Responsive Overrides */
1822 + // Layer 2: CSS isolation + responsive overrides — always included.
1823 + // Page builders (Elementor, Bricks, Divi, Beaver) inject global CSS
1824 + // that can override display, margin, padding, and box-sizing on generic
1825 + // elements. The mxg-wrapper scope ensures our layout rules take priority.
1826 + $css .= '/* MxChat — CSS Isolation & Responsive Overrides */
1961 1827 .mxg-wrapper { box-sizing: border-box; }
1962 1828 .mxg-wrapper *, .mxg-wrapper *::before, .mxg-wrapper *::after { box-sizing: inherit; }
1963 1829 .mxg-wrapper img { max-width: 100%; height: auto; }
1964 1830 .mxg-wrapper section { clear: both; }
@@ -1981,39 +1847,17 @@
1981 1847 .mxg-section-heading { font-size: 1.35rem !important; }
1982 1848 .mxg-container { padding-left: 16px !important; padding-right: 16px !important; }
1983 1849 }
1984 1850 ';
1985 - }
1986 1851
1987 - /**
1988 - * Title-hiding CSS for WordPress themes.
1989 - */
1990 - private function get_title_hide_css() {
1991 - return '/* MxChat — Hide Title */
1992 -.entry-title,
1993 -.page-title,
1994 -.post-title,
1995 -.wp-block-post-title,
1996 -.ast-title-with-post-meta-wrapper,
1997 -.ast-the-title,
1998 -.generate-page-header .page-hero,
1999 -.entry-header .entry-title,
2000 -.entry-hero .entry-title,
2001 -.kadence-page-title,
2002 -.wp-site-blocks .entry-title,
2003 -.ocean-single-post-header,
2004 -.page-header,
2005 -.nv-page-title-wrap,
2006 -.nv-post-title,
2007 -.elementor-page-title,
2008 -.et_pb_title_container .entry-title,
2009 -.brxe-post-title,
2010 -[data-hero] .page-title,
2011 -.hero-section .page-title,
2012 -.entry-header {
2013 - display: none !important;
2014 -}
2015 -';
1852 + // Layer 3: AI-generated CSS (page-specific design)
1853 + if (!empty($ai_css)) {
1854 + $css .= "\n/* MxChat — AI Generated Styles */\n";
1855 + $css .= $ai_css . "\n";
1856 + }
1857 +
1858 + $css .= '</style>';
1859 + return $css;
2016 1860 }
2017 1861
2018 1862 /**
2019 1863 * Apply layout settings via theme-specific and builder-specific post meta.
@@ -2166,101 +2010,18 @@
2166 2010 $keywords_string = implode(', ', $keywords);
2167 2011
2168 2012 $this->set_meta_description($post_id, $meta_description);
2169 2013 $this->set_focus_keyword($post_id, !empty($focus_keyword) ? $focus_keyword : $keywords_string);
2170 - $this->set_seo_title($post_id, $plan['title'] ?? '');
2171 2014 }
2172 2015
2173 - /**
2174 - * Write the SEO title to the active SEO plugin's title postmeta so
2175 - * RankMath / Yoast / AIOSEO don't fall back to the bare post_title.
2176 - * (plan-mxchat-20260510-b89332)
2177 - *
2178 - * v1: use the generated post_title as the SEO title. A future plan may
2179 - * add a separately-generated 50-58 char SEO-optimized title.
2180 - */
2181 - private function set_seo_title($post_id, $title) {
2182 - $title = sanitize_text_field($title);
2183 - if (empty($title)) {
2184 - return;
2185 - }
2186 - $plugin = $this->get_seo_plugin();
2187 - $keys = array(
2188 - 'rankmath' => 'rank_math_title',
2189 - 'yoast' => '_yoast_wpseo_title',
2190 - 'aioseo' => '_aioseo_title',
2191 - );
2192 - if (isset($keys[$plugin])) {
2193 - update_post_meta($post_id, $keys[$plugin], $title);
2194 - }
2195 - }
2196 -
2197 - /**
2198 - * Walk the rendered body HTML and copy the inline `<img alt="...">` text
2199 - * for each generated image into its attachment's _wp_attachment_image_alt
2200 - * postmeta. Matches inline images to attachments by URL.
2201 - * (plan-mxchat-20260510-2be2da)
2202 - */
2203 - private function sync_image_alts_from_content($content, $image_urls) {
2204 - if (empty($content) || empty($image_urls)) {
2205 - return;
2206 - }
2207 -
2208 - // Build a url => attachment_id lookup for fast matching.
2209 - $by_url = array();
2210 - foreach ($image_urls as $img) {
2211 - if (!empty($img['url']) && !empty($img['attachment_id'])) {
2212 - $by_url[$img['url']] = (int) $img['attachment_id'];
2213 - }
2214 - }
2215 - if (empty($by_url)) {
2216 - return;
2217 - }
2218 -
2219 - if (!preg_match_all('/<img\b[^>]*>/i', $content, $matches)) {
2220 - return;
2221 - }
2222 -
2223 - foreach ($matches[0] as $img_tag) {
2224 - if (!preg_match('/\bsrc\s*=\s*"([^"]+)"/i', $img_tag, $src_match)) {
2225 - continue;
2226 - }
2227 - $src = html_entity_decode($src_match[1], ENT_QUOTES);
2228 - if (!isset($by_url[$src])) {
2229 - continue;
2230 - }
2231 - if (!preg_match('/\balt\s*=\s*"([^"]*)"/i', $img_tag, $alt_match)) {
2232 - continue;
2233 - }
2234 - $alt = sanitize_text_field(html_entity_decode($alt_match[1], ENT_QUOTES));
2235 - if ($alt !== '') {
2236 - update_post_meta($by_url[$src], '_wp_attachment_image_alt', $alt);
2237 - }
2238 - }
2239 - }
2240 -
2241 2016 // ─── AI Model Caller ────────────────────────────────────────────────
2242 2017
2243 2018 /**
2244 2019 * Call the configured content model
2245 2020 */
2246 - /**
2247 - * Provider slugs (MxChat_Model_Catalog keys) that call_content_model() can
2248 - * dispatch to. MUST mirror the if/elseif chain below — the Content Model
2249 - * picker derives its provider set from this list (plan ccb751), so a
2250 - * provider added to the dispatch without updating this is never offered,
2251 - * and one added here without dispatch support would break generation.
2252 - * OpenRouter and the custom OpenAI-compatible provider are absent on
2253 - * purpose: their pseudo-model ids would fall through to the OpenAI default
2254 - * branch with the wrong key and endpoint.
2255 - */
2256 - public static function supported_content_providers() {
2257 - return array('openai', 'claude', 'gemini', 'xai', 'deepseek');
2258 - }
2259 -
2260 2021 private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
2261 2022 $options = get_option('mxchat_options', array());
2262 - $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol';
2023 + $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest';
2263 2024
2264 2025 // Determine provider from model name
2265 2026 if ($this->is_claude_model($model)) {
2266 2027 return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
@@ -2278,23 +2039,8 @@
2278 2039
2279 2040 /**
2280 2041 * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
2281 2042 */
2282 - /**
2283 - * plan-mxchat-20260714-dcb71c: frozen pre-dcb71c 'content' reasoning ladder,
2284 - * used only when the core model catalog is unavailable. Byte-identical to the
2285 - * old inline if/elseif at the call site.
2286 - */
2287 - private function mxchat_cg_reasoning_effort_fallback($model) {
2288 - if (strpos($model, 'gpt-5') !== 0) return null;
2289 - if ($model === 'gpt-5.2' || $model === 'gpt-5.1-chat-latest') return null;
2290 - if ($model === 'gpt-5.1-2025-11-13') return 'low';
2291 - if ($model === 'gpt-5.5') return 'low';
2292 - if ($model === 'gpt-5.4') return 'low';
2293 - if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
2294 - return 'minimal';
2295 - }
2296 -
2297 2043 private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
2298 2044 if (empty($api_key)) {
2299 2045 return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
2300 2046 }
@@ -2307,17 +2053,11 @@
2307 2053 'content' => $msg['content'] ?? '',
2308 2054 );
2309 2055 }
2310 2056
2311 - // GPT-5.x models require max_completion_tokens and only support temperature=1.
2312 - // Token key, temperature gate, and reasoning_effort are sourced from the
2313 - // core model catalog (plan-dcb71c); frozen fallbacks preserve exact
2314 - // pre-dcb71c behavior if the catalog class is unavailable.
2315 - $is_gpt5 = strpos($model, 'gpt-5') === 0;
2316 - $catalog = class_exists('MxChat_Model_Catalog');
2317 - $token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param'))
2318 - ? MxChat_Model_Catalog::openai_token_param($model)
2319 - : ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens');
2057 + // GPT-5.x models require max_completion_tokens and only support temperature=1
2058 + $is_gpt5 = strpos($model, 'gpt-5') === 0;
2059 + $token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens';
2320 2060
2321 2061 $body = array(
2322 2062 'model' => $model,
2323 2063 'messages' => $formatted,
@@ -2324,30 +2064,23 @@
2324 2064 $token_key => $max_tokens,
2325 2065 'stream' => false,
2326 2066 );
2327 2067
2328 - $temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2329 - ? MxChat_Model_Catalog::supports_temperature($model)
2330 - : !$is_gpt5;
2331 - if ($temp_ok) {
2068 + if (!$is_gpt5) {
2332 2069 $body['temperature'] = 0.7;
2333 2070 }
2334 2071
2335 - $effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for'))
2336 - ? MxChat_Model_Catalog::reasoning_effort_for($model, 'content')
2337 - : $this->mxchat_cg_reasoning_effort_fallback($model);
2338 - if ($effort !== null) {
2339 - $body['reasoning_effort'] = $effort;
2072 + // Add reasoning_effort only for GPT-5 models that support it
2073 + // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
2074 + if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') {
2075 + // GPT-5.1 uses 'low' instead of 'minimal'
2076 + if ($model === 'gpt-5.1-2025-11-13') {
2077 + $body['reasoning_effort'] = 'low';
2078 + } else {
2079 + $body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
2080 + }
2340 2081 }
2341 2082
2342 - // DeepSeek V4 defaults to thinking mode ON (temperature ignored and
2343 - // reasoning eats the token budget); generation wants the legacy
2344 - // deepseek-chat semantics = non-thinking. Endpoint-gated because this
2345 - // helper is shared with OpenAI and xAI.
2346 - if (strpos($endpoint, 'api.deepseek.com') !== false) {
2347 - $body['thinking'] = array('type' => 'disabled');
2348 - }
2349 -
2350 2083 // Scale timeout with token count — large generation calls need more time
2351 2084 $timeout = ($max_tokens > 8000) ? 300 : 120;
2352 2085
2353 2086 $response = wp_remote_post($endpoint, array(
@@ -2365,38 +2098,8 @@
2365 2098
2366 2099 $status_code = wp_remote_retrieve_response_code($response);
2367 2100 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2368 2101
2369 - // plan-25b972 self-heal: supported reasoning_effort VALUES are per-model;
2370 - // a 400 rejecting the value we sent (stale catalog entry / provider
2371 - // drift) is deterministic — strip the param and retry ONCE.
2372 - if ($status_code === 400
2373 - && isset($body['reasoning_effort'])
2374 - && isset($decoded['error']['message'])
2375 - && is_string($decoded['error']['message'])
2376 - && preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) {
2377 - if (defined('WP_DEBUG') && WP_DEBUG) {
2378 - error_log(sprintf(
2379 - '[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).',
2380 - $model, $body['reasoning_effort']
2381 - ));
2382 - }
2383 - unset($body['reasoning_effort']);
2384 - $response = wp_remote_post($endpoint, array(
2385 - 'headers' => array(
2386 - 'Authorization' => 'Bearer ' . $api_key,
2387 - 'Content-Type' => 'application/json',
2388 - ),
2389 - 'body' => wp_json_encode($body),
2390 - 'timeout' => $timeout,
2391 - ));
2392 - if (is_wp_error($response)) {
2393 - return $response;
2394 - }
2395 - $status_code = wp_remote_retrieve_response_code($response);
2396 - $decoded = json_decode(wp_remote_retrieve_body($response), true);
2397 - }
2398 -
2399 2102 if ($status_code !== 200) {
2400 2103 $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
2401 2104 return new WP_Error('api_error', $error_msg);
2402 2105 }
@@ -2411,12 +2114,8 @@
2411 2114 /**
2412 2115 * Call Claude (Anthropic) API
2413 2116 */
2414 2117 private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
2415 - // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
2416 - // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
2417 - if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
2418 - elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
2419 2118 if (empty($api_key)) {
2420 2119 return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
2421 2120 }
2422 2121
@@ -2436,25 +2135,11 @@
2436 2135 'model' => $model,
2437 2136 'max_tokens' => $max_tokens,
2438 2137 'temperature' => 0.7,
2439 2138 'messages' => $formatted,
2440 - // Prompt-cache breakpoint (plan 1ff43b): generation pipelines reuse
2441 - // the same system prompt across consecutive requests within the
2442 - // 5-minute cache window. Silently no-ops below the model minimum.
2443 - 'system' => trim((string) $system_prompt) === '' ? $system_prompt : array(
2444 - array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')),
2445 - ),
2139 + 'system' => $system_prompt,
2446 2140 );
2447 2141
2448 - // Anthropic removed temperature on Opus 4.7+ flagships (400 if sent) —
2449 - // sourced from the core model catalog (plan-dcb71c); frozen list fallback.
2450 - $omit_temp = (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2451 - ? !MxChat_Model_Catalog::supports_temperature($model)
2452 - : in_array($model, array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5'), true);
2453 - if ($omit_temp) {
2454 - unset($body['temperature']);
2455 - }
2456 -
2457 2142 $timeout = ($max_tokens > 8000) ? 300 : 120;
2458 2143
2459 2144 $response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
2460 2145 'headers' => array(
@@ -2477,16 +2162,10 @@
2477 2162 $error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code;
2478 2163 return new WP_Error('claude_error', $error_msg);
2479 2164 }
2480 2165
2481 - // claude-fable-5 prepends a thinking block to content — take the
2482 - // first TEXT block, not content[0].
2483 - if (isset($decoded['content']) && is_array($decoded['content'])) {
2484 - foreach ($decoded['content'] as $block) {
2485 - if (isset($block['type'], $block['text']) && $block['type'] === 'text') {
2486 - return trim($block['text']);
2487 - }
2488 - }
2166 + if (isset($decoded['content'][0]['text'])) {
2167 + return trim($decoded['content'][0]['text']);
2489 2168 }
2490 2169
2491 2170 return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat'));
2492 2171 }
@@ -2495,11 +2174,8 @@
2495 2174 /**
2496 2175 * Call Gemini API
2497 2176 */
2498 2177 private function call_gemini($model, $api_key, $system_prompt, $messages, $max_tokens) {
2499 - if ($model === 'gemini-3-pro-preview') {
2500 - $model = 'gemini-3.1-pro-preview';
2501 - }
2502 2178 if (empty($api_key)) {
2503 2179 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
2504 2180 }
2505 2181
@@ -2596,9 +2272,9 @@
2596 2272
2597 2273 $field = sanitize_text_field($_POST['field'] ?? '');
2598 2274 $value = sanitize_text_field($_POST['value'] ?? '');
2599 2275
2600 - $allowed_fields = array('content_model', 'content_image_model', 'content_image_quality', 'content_image_count', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img');
2276 + $allowed_fields = array('content_model', 'content_image_model', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img');
2601 2277 if (!in_array($field, $allowed_fields, true)) {
2602 2278 wp_send_json_error(array('message' => __('Invalid field.', 'mxchat')));
2603 2279 }
2604 2280
@@ -2606,18 +2282,8 @@
2606 2282 if (in_array($field, array('content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use', 'seo_optimize_meta_desc', 'seo_optimize_seo_title', 'seo_optimize_slug', 'seo_optimize_readability', 'seo_optimize_internal_links', 'seo_optimize_img_alt', 'seo_optimize_featured_img'), true)) {
2607 2283 $value = ($value === 'on') ? 'on' : 'off';
2608 2284 }
2609 2285
2610 - // Enum field: image quality
2611 - if ($field === 'content_image_quality') {
2612 - $value = in_array($value, array('auto', 'low', 'medium', 'high'), true) ? $value : 'auto';
2613 - }
2614 -
2615 - // Integer field: images per article (clamp 1–5)
2616 - if ($field === 'content_image_count') {
2617 - $value = (string) max(1, min(5, (int) $value));
2618 - }
2619 -
2620 2286 $options = get_option('mxchat_options', array());
2621 2287 $options[$field] = $value;
2622 2288 update_option('mxchat_options', $options);
2623 2289
@@ -3334,9 +3000,9 @@
3334 3000
3335 3001 $args = array(
3336 3002 'post_status' => 'publish',
3337 3003 'posts_per_page' => -1,
3338 - 'post_type' => $post_type === 'any' ? array_values(array_diff(get_post_types(array('public' => true), 'names'), array('attachment'))) : $post_type,
3004 + 'post_type' => $post_type === 'any' ? array('post', 'page') : $post_type,
3339 3005 'orderby' => $orderby,
3340 3006 'order' => $sort_order,
3341 3007 'fields' => 'ids',
3342 3008 );
@@ -3359,39 +3025,23 @@
3359 3025 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3360 3026 );
3361 3027 }
3362 3028
3363 - // When sorting by a meta field, posts WITHOUT the key must stay in the
3364 - // list — GSC meta is no longer zero-filled for no-data posts (plan
3365 - // 282c80), and score meta never existed for unanalyzed posts. A single
3366 - // OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an
3367 - // OR relation WP_Meta_Query leaves the orderby clause's join alias
3368 - // unconstrained by meta_key, so ORDER BY reads arbitrary meta rows).
3369 - // Instead: two id-only queries — posts WITH the key sorted by its
3370 - // value, then posts WITHOUT it appended (newest first). Missing-data
3371 - // rows deliberately sort last in BOTH directions.
3029 + // When sorting by a meta field, ensure meta_key is set for ordering.
3030 + // For 'all' filter, include posts without the meta key via OR clause.
3372 3031 if ($sort_meta_key) {
3373 - $with_args = $args;
3374 - $with_args['meta_key'] = $sort_meta_key;
3375 - $with_args['orderby'] = 'meta_value_num';
3376 - $q_with = new \WP_Query($with_args);
3377 -
3378 - $without_args = $args;
3379 - $missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS');
3380 - if (!empty($without_args['meta_query'])) {
3381 - $without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause);
3382 - } else {
3383 - $without_args['meta_query'] = array($missing_clause);
3032 + $args['meta_key'] = $sort_meta_key;
3033 + if ($filter === 'all') {
3034 + $args['meta_query'] = array(
3035 + 'relation' => 'OR',
3036 + array('key' => $sort_meta_key, 'compare' => 'EXISTS'),
3037 + array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'),
3038 + );
3384 3039 }
3385 - $without_args['orderby'] = 'date';
3386 - $without_args['order'] = 'DESC';
3387 - $q_without = new \WP_Query($without_args);
3040 + }
3388 3041
3389 - $all_ids = array_merge($q_with->posts, $q_without->posts);
3390 - } else {
3391 - $query = new \WP_Query($args);
3392 - $all_ids = $query->posts;
3393 - }
3042 + $query = new \WP_Query($args);
3043 + $all_ids = $query->posts;
3394 3044 $total = count($all_ids);
3395 3045 $pages = max(1, ceil($total / $per_page));
3396 3046 $page = min($page, $pages);
3397 3047 $offset = ($page - 1) * $per_page;