PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 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 All 156 releases
← All changes | includes/class-mxchat-content-generator.php +289 -37 3.2.1 → 3.2.25 View file →
@@ -511,8 +511,14 @@
511 511 if (!empty($image_ids)) {
512 512 update_post_meta($post_id, '_mxchat_image_ids', $image_ids);
513 513 }
514 514
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 +
515 521 // Apply fullwidth/title settings via theme-specific post meta
516 522 $this->apply_layout_settings($post_id, $layout, $title_display);
517 523
518 524 // Step 5: Fill SEO metadata
@@ -725,10 +731,21 @@
725 731 */
726 732 private function plan_content($prompt, $content_type) {
727 733 $type_label = ($content_type === 'page') ? 'landing page' : 'blog post';
728 734
729 - $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 [].";
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 + }
730 745
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 +
731 748 $messages = array(
732 749 array('role' => 'user', 'content' => "Create a {$type_label} about: {$prompt}"),
733 750 );
734 751
@@ -782,8 +799,15 @@
782 799 );
783 800 }
784 801 }
785 802
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 +
786 810 if (empty($image_sections)) {
787 811 return array();
788 812 }
789 813
@@ -972,9 +996,9 @@
972 996 'model' => 'gpt-image-1.5',
973 997 'prompt' => $prompt,
974 998 'n' => 1,
975 999 'size' => '1536x1024',
976 - 'quality' => 'medium',
1000 + 'quality' => $options['content_image_quality'] ?? 'auto',
977 1001 'output_format' => 'png',
978 1002 )),
979 1003 );
980 1004 }
@@ -1096,9 +1120,9 @@
1096 1120 'model' => 'gpt-image-1.5',
1097 1121 'prompt' => $prompt,
1098 1122 'n' => 1,
1099 1123 'size' => '1536x1024',
1100 - 'quality' => 'medium',
1124 + 'quality' => $options['content_image_quality'] ?? 'auto',
1101 1125 'output_format' => 'png',
1102 1126 );
1103 1127
1104 1128 $response = wp_remote_post('https://api.openai.com/v1/images/generations', array(
@@ -1590,8 +1614,10 @@
1590 1614 - Alternate light (#ffffff) and subtle gray (#f8fafc) backgrounds
1591 1615 - Two-column layouts alternating content left/right
1592 1616 - Card grids for features/benefits
1593 1617 - 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
1594 1620
1595 1621 TYPOGRAPHY:
1596 1622 - Hero heading: 3rem desktop, scales down in your media queries
1597 1623 - Section headings: 2.25rem desktop
@@ -1906,8 +1932,25 @@
1906 1932 padding: 0 !important;
1907 1933 max-width: 100% !important;
1908 1934 width: 100% !important;
1909 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 + padding-left: 0 !important;
1944 + padding-right: 0 !important;
1945 + max-width: 100% !important;
1946 +}
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 +}
1910 1953 ';
1911 1954 }
1912 1955
1913 1956 /**
@@ -2123,18 +2166,101 @@
2123 2166 $keywords_string = implode(', ', $keywords);
2124 2167
2125 2168 $this->set_meta_description($post_id, $meta_description);
2126 2169 $this->set_focus_keyword($post_id, !empty($focus_keyword) ? $focus_keyword : $keywords_string);
2170 + $this->set_seo_title($post_id, $plan['title'] ?? '');
2127 2171 }
2128 2172
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 +
2129 2241 // ─── AI Model Caller ────────────────────────────────────────────────
2130 2242
2131 2243 /**
2132 2244 * Call the configured content model
2133 2245 */
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 +
2134 2260 private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
2135 2261 $options = get_option('mxchat_options', array());
2136 - $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest';
2262 + $model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.6-sol';
2137 2263
2138 2264 // Determine provider from model name
2139 2265 if ($this->is_claude_model($model)) {
2140 2266 return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
@@ -2152,8 +2278,24 @@
2152 2278
2153 2279 /**
2154 2280 * Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
2155 2281 */
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 && strpos($model, 'gpt-6') !== 0) return null; // gpt-6 too (plan 45c22d)
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-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) return 'low'; // plan 45c22d
2294 + if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
2295 + return 'minimal';
2296 + }
2297 +
2156 2298 private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
2157 2299 if (empty($api_key)) {
2158 2300 return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
2159 2301 }
@@ -2166,11 +2308,17 @@
2166 2308 'content' => $msg['content'] ?? '',
2167 2309 );
2168 2310 }
2169 2311
2170 - // GPT-5.x models require max_completion_tokens and only support temperature=1
2171 - $is_gpt5 = strpos($model, 'gpt-5') === 0;
2172 - $token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens';
2312 + // GPT-5.x models require max_completion_tokens and only support temperature=1.
2313 + // Token key, temperature gate, and reasoning_effort are sourced from the
2314 + // core model catalog (plan-dcb71c); frozen fallbacks preserve exact
2315 + // pre-dcb71c behavior if the catalog class is unavailable.
2316 + $is_gpt5 = strpos($model, 'gpt-5') === 0 || strpos($model, 'gpt-6') === 0; // gpt-6 too (plan 45c22d)
2317 + $catalog = class_exists('MxChat_Model_Catalog');
2318 + $token_key = ($catalog && method_exists('MxChat_Model_Catalog', 'openai_token_param'))
2319 + ? MxChat_Model_Catalog::openai_token_param($model)
2320 + : ($is_gpt5 ? 'max_completion_tokens' : 'max_tokens');
2173 2321
2174 2322 $body = array(
2175 2323 'model' => $model,
2176 2324 'messages' => $formatted,
@@ -2177,25 +2325,30 @@
2177 2325 $token_key => $max_tokens,
2178 2326 'stream' => false,
2179 2327 );
2180 2328
2181 - if (!$is_gpt5) {
2329 + $temp_ok = ($catalog && method_exists('MxChat_Model_Catalog', 'supports_temperature'))
2330 + ? MxChat_Model_Catalog::supports_temperature($model)
2331 + : !$is_gpt5;
2332 + if ($temp_ok) {
2182 2333 $body['temperature'] = 0.7;
2183 2334 }
2184 2335
2185 - // Add reasoning_effort only for GPT-5 models that support it
2186 - // gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
2187 - if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') {
2188 - // GPT-5.1 uses 'low' instead of 'minimal'
2189 - if ($model === 'gpt-5.1-2025-11-13') {
2190 - $body['reasoning_effort'] = 'low';
2191 - } elseif ($model === 'gpt-5.4') {
2192 - $body['reasoning_effort'] = 'low';
2193 - } else {
2194 - $body['reasoning_effort'] = 'minimal';
2195 - }
2336 + $effort = ($catalog && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for'))
2337 + ? MxChat_Model_Catalog::reasoning_effort_for($model, 'content')
2338 + : $this->mxchat_cg_reasoning_effort_fallback($model);
2339 + if ($effort !== null) {
2340 + $body['reasoning_effort'] = $effort;
2196 2341 }
2197 2342
2343 + // DeepSeek V4 defaults to thinking mode ON (temperature ignored and
2344 + // reasoning eats the token budget); generation wants the legacy
2345 + // deepseek-chat semantics = non-thinking. Endpoint-gated because this
2346 + // helper is shared with OpenAI and xAI.
2347 + if (strpos($endpoint, 'api.deepseek.com') !== false) {
2348 + $body['thinking'] = array('type' => 'disabled');
2349 + }
2350 +
2198 2351 // Scale timeout with token count — large generation calls need more time
2199 2352 $timeout = ($max_tokens > 8000) ? 300 : 120;
2200 2353
2201 2354 $response = wp_remote_post($endpoint, array(
@@ -2213,8 +2366,38 @@
2213 2366
2214 2367 $status_code = wp_remote_retrieve_response_code($response);
2215 2368 $decoded = json_decode(wp_remote_retrieve_body($response), true);
2216 2369
2370 + // plan-25b972 self-heal: supported reasoning_effort VALUES are per-model;
2371 + // a 400 rejecting the value we sent (stale catalog entry / provider
2372 + // drift) is deterministic — strip the param and retry ONCE.
2373 + if ($status_code === 400
2374 + && isset($body['reasoning_effort'])
2375 + && isset($decoded['error']['message'])
2376 + && is_string($decoded['error']['message'])
2377 + && preg_match('/Unsupported value:.*reasoning_effort/i', $decoded['error']['message'])) {
2378 + if (defined('WP_DEBUG') && WP_DEBUG) {
2379 + error_log(sprintf(
2380 + '[MxChat] content-generator: model %s rejected reasoning_effort \'%s\' — retrying once without the param (plan-25b972).',
2381 + $model, $body['reasoning_effort']
2382 + ));
2383 + }
2384 + unset($body['reasoning_effort']);
2385 + $response = wp_remote_post($endpoint, array(
2386 + 'headers' => array(
2387 + 'Authorization' => 'Bearer ' . $api_key,
2388 + 'Content-Type' => 'application/json',
2389 + ),
2390 + 'body' => wp_json_encode($body),
2391 + 'timeout' => $timeout,
2392 + ));
2393 + if (is_wp_error($response)) {
2394 + return $response;
2395 + }
2396 + $status_code = wp_remote_retrieve_response_code($response);
2397 + $decoded = json_decode(wp_remote_retrieve_body($response), true);
2398 + }
2399 +
2217 2400 if ($status_code !== 200) {
2218 2401 $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
2219 2402 return new WP_Error('api_error', $error_msg);
2220 2403 }
@@ -2229,8 +2412,11 @@
2229 2412 /**
2230 2413 * Call Claude (Anthropic) API
2231 2414 */
2232 2415 private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
2416 + // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
2417 + // Read-time rescue: a saved retired id is remapped to the catalog's replacement before the API call.
2418 + $model = MxChat_Model_Catalog::resolve_retired($model); // plan 8c4eb4: the catalog's one map
2233 2419 if (empty($api_key)) {
2234 2420 return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
2235 2421 }
2236 2422
@@ -2250,11 +2436,25 @@
2250 2436 'model' => $model,
2251 2437 'max_tokens' => $max_tokens,
2252 2438 'temperature' => 0.7,
2253 2439 'messages' => $formatted,
2254 - 'system' => $system_prompt,
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 + ),
2255 2446 );
2256 2447
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 +
2257 2457 $timeout = ($max_tokens > 8000) ? 300 : 120;
2258 2458
2259 2459 $response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
2260 2460 'headers' => array(
@@ -2277,10 +2477,16 @@
2277 2477 $error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code;
2278 2478 return new WP_Error('claude_error', $error_msg);
2279 2479 }
2280 2480
2281 - if (isset($decoded['content'][0]['text'])) {
2282 - return trim($decoded['content'][0]['text']);
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 + }
2283 2489 }
2284 2490
2285 2491 return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat'));
2286 2492 }
@@ -2289,8 +2495,11 @@
2289 2495 /**
2290 2496 * Call Gemini API
2291 2497 */
2292 2498 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 + }
2293 2502 if (empty($api_key)) {
2294 2503 return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
2295 2504 }
2296 2505
@@ -2346,12 +2555,29 @@
2346 2555 $error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code;
2347 2556 return new WP_Error('gemini_error', $error_msg);
2348 2557 }
2349 2558
2350 - if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) {
2351 - return trim($decoded['candidates'][0]['content']['parts'][0]['text']);
2559 + // plan 781afd: join every text part (skip thought parts), not only parts[0].
2560 + $text = '';
2561 + if (isset($decoded['candidates'][0]['content']['parts']) && is_array($decoded['candidates'][0]['content']['parts'])) {
2562 + foreach ($decoded['candidates'][0]['content']['parts'] as $part) {
2563 + if (is_array($part) && isset($part['text']) && is_string($part['text']) && empty($part['thought'])) {
2564 + $text .= $part['text'];
2565 + }
2566 + }
2352 2567 }
2568 + $text = trim($text);
2569 + if ($text !== '') {
2570 + return $text;
2571 + }
2353 2572
2573 + $no_text_reason = isset($decoded['candidates'][0]['finishReason']) && is_string($decoded['candidates'][0]['finishReason'])
2574 + ? $decoded['candidates'][0]['finishReason']
2575 + : (isset($decoded['promptFeedback']['blockReason']) && is_string($decoded['promptFeedback']['blockReason']) ? $decoded['promptFeedback']['blockReason'] : '');
2576 + if ($no_text_reason !== '') {
2577 + /* translators: %s: Gemini's finish or block reason, e.g. SAFETY */
2578 + return new WP_Error('gemini_no_text', sprintf(__('Gemini returned no text (%s).', 'mxchat'), $no_text_reason));
2579 + }
2354 2580 return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat'));
2355 2581 }
2356 2582
2357 2583 // ─── Model Detection Helpers ────────────────────────────────────────
@@ -2387,9 +2613,9 @@
2387 2613
2388 2614 $field = sanitize_text_field($_POST['field'] ?? '');
2389 2615 $value = sanitize_text_field($_POST['value'] ?? '');
2390 2616
2391 - $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');
2617 + $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');
2392 2618 if (!in_array($field, $allowed_fields, true)) {
2393 2619 wp_send_json_error(array('message' => __('Invalid field.', 'mxchat')));
2394 2620 }
2395 2621
@@ -2397,8 +2623,18 @@
2397 2623 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)) {
2398 2624 $value = ($value === 'on') ? 'on' : 'off';
2399 2625 }
2400 2626
2627 + // Enum field: image quality
2628 + if ($field === 'content_image_quality') {
2629 + $value = in_array($value, array('auto', 'low', 'medium', 'high'), true) ? $value : 'auto';
2630 + }
2631 +
2632 + // Integer field: images per article (clamp 1–5)
2633 + if ($field === 'content_image_count') {
2634 + $value = (string) max(1, min(5, (int) $value));
2635 + }
2636 +
2401 2637 $options = get_option('mxchat_options', array());
2402 2638 $options[$field] = $value;
2403 2639 update_option('mxchat_options', $options);
2404 2640
@@ -3140,23 +3376,39 @@
3140 3376 array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'),
3141 3377 );
3142 3378 }
3143 3379
3144 - // When sorting by a meta field, ensure meta_key is set for ordering.
3145 - // For 'all' filter, include posts without the meta key via OR clause.
3380 + // When sorting by a meta field, posts WITHOUT the key must stay in the
3381 + // list — GSC meta is no longer zero-filled for no-data posts (plan
3382 + // 282c80), and score meta never existed for unanalyzed posts. A single
3383 + // OR EXISTS/NOT-EXISTS meta_query cannot order this reliably (under an
3384 + // OR relation WP_Meta_Query leaves the orderby clause's join alias
3385 + // unconstrained by meta_key, so ORDER BY reads arbitrary meta rows).
3386 + // Instead: two id-only queries — posts WITH the key sorted by its
3387 + // value, then posts WITHOUT it appended (newest first). Missing-data
3388 + // rows deliberately sort last in BOTH directions.
3146 3389 if ($sort_meta_key) {
3147 - $args['meta_key'] = $sort_meta_key;
3148 - if ($filter === 'all') {
3149 - $args['meta_query'] = array(
3150 - 'relation' => 'OR',
3151 - array('key' => $sort_meta_key, 'compare' => 'EXISTS'),
3152 - array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS'),
3153 - );
3390 + $with_args = $args;
3391 + $with_args['meta_key'] = $sort_meta_key;
3392 + $with_args['orderby'] = 'meta_value_num';
3393 + $q_with = new \WP_Query($with_args);
3394 +
3395 + $without_args = $args;
3396 + $missing_clause = array('key' => $sort_meta_key, 'compare' => 'NOT EXISTS');
3397 + if (!empty($without_args['meta_query'])) {
3398 + $without_args['meta_query'] = array('relation' => 'AND', $without_args['meta_query'], $missing_clause);
3399 + } else {
3400 + $without_args['meta_query'] = array($missing_clause);
3154 3401 }
3402 + $without_args['orderby'] = 'date';
3403 + $without_args['order'] = 'DESC';
3404 + $q_without = new \WP_Query($without_args);
3405 +
3406 + $all_ids = array_merge($q_with->posts, $q_without->posts);
3407 + } else {
3408 + $query = new \WP_Query($args);
3409 + $all_ids = $query->posts;
3155 3410 }
3156 -
3157 - $query = new \WP_Query($args);
3158 - $all_ids = $query->posts;
3159 3411 $total = count($all_ids);
3160 3412 $pages = max(1, ceil($total / $per_page));
3161 3413 $page = min($page, $pages);
3162 3414 $offset = ($page - 1) * $per_page;