| @@ -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,23 @@ | ||
| 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) 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 | + | |
| 2156 | 2297 | private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) { |
| 2157 | 2298 | if (empty($api_key)) { |
| 2158 | 2299 | return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat')); |
| 2159 | 2300 | } |
| @@ -2166,11 +2307,17 @@ | ||
| 2166 | 2307 | 'content' => $msg['content'] ?? '', |
| 2167 | 2308 | ); |
| 2168 | 2309 | } |
| 2169 | 2310 | |
| 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'; | |
| 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'); | |
| 2173 | 2320 | |
| 2174 | 2321 | $body = array( |
| 2175 | 2322 | 'model' => $model, |
| 2176 | 2323 | 'messages' => $formatted, |
| @@ -2177,25 +2324,30 @@ | ||
| 2177 | 2324 | $token_key => $max_tokens, |
| 2178 | 2325 | 'stream' => false, |
| 2179 | 2326 | ); |
| 2180 | 2327 | |
| 2181 | - if (!$is_gpt5) { | |
| 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) { | |
| 2182 | 2332 | $body['temperature'] = 0.7; |
| 2183 | 2333 | } |
| 2184 | 2334 | |
| 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 | - } | |
| 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; | |
| 2196 | 2340 | } |
| 2197 | 2341 | |
| 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 | + | |
| 2198 | 2350 | // Scale timeout with token count — large generation calls need more time |
| 2199 | 2351 | $timeout = ($max_tokens > 8000) ? 300 : 120; |
| 2200 | 2352 | |
| 2201 | 2353 | $response = wp_remote_post($endpoint, array( |
| @@ -2213,8 +2365,38 @@ | ||
| 2213 | 2365 | |
| 2214 | 2366 | $status_code = wp_remote_retrieve_response_code($response); |
| 2215 | 2367 | $decoded = json_decode(wp_remote_retrieve_body($response), true); |
| 2216 | 2368 | |
| 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 | + | |
| 2217 | 2399 | if ($status_code !== 200) { |
| 2218 | 2400 | $error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code; |
| 2219 | 2401 | return new WP_Error('api_error', $error_msg); |
| 2220 | 2402 | } |
| @@ -2229,8 +2411,12 @@ | ||
| 2229 | 2411 | /** |
| 2230 | 2412 | * Call Claude (Anthropic) API |
| 2231 | 2413 | */ |
| 2232 | 2414 | 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'; } | |
| 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 | |
| @@ -2387,9 +2596,9 @@ | ||
| 2387 | 2596 | |
| 2388 | 2597 | $field = sanitize_text_field($_POST['field'] ?? ''); |
| 2389 | 2598 | $value = sanitize_text_field($_POST['value'] ?? ''); |
| 2390 | 2599 | |
| 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'); | |
| 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'); | |
| 2392 | 2601 | if (!in_array($field, $allowed_fields, true)) { |
| 2393 | 2602 | wp_send_json_error(array('message' => __('Invalid field.', 'mxchat'))); |
| 2394 | 2603 | } |
| 2395 | 2604 | |
| @@ -2397,8 +2606,18 @@ | ||
| 2397 | 2606 | 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 | 2607 | $value = ($value === 'on') ? 'on' : 'off'; |
| 2399 | 2608 | } |
| 2400 | 2609 | |
| 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 | + | |
| 2401 | 2620 | $options = get_option('mxchat_options', array()); |
| 2402 | 2621 | $options[$field] = $value; |
| 2403 | 2622 | update_option('mxchat_options', $options); |
| 2404 | 2623 | |
| @@ -3140,23 +3359,39 @@ | ||
| 3140 | 3359 | array('key' => '_mxchat_seo_score', 'compare' => 'NOT EXISTS'), |
| 3141 | 3360 | ); |
| 3142 | 3361 | } |
| 3143 | 3362 | |
| 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. | |
| 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. | |
| 3146 | 3372 | 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 | - ); | |
| 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); | |
| 3154 | 3384 | } |
| 3385 | + $without_args['orderby'] = 'date'; | |
| 3386 | + $without_args['order'] = 'DESC'; | |
| 3387 | + $q_without = new \WP_Query($without_args); | |
| 3388 | + | |
| 3389 | + $all_ids = array_merge($q_with->posts, $q_without->posts); | |
| 3390 | + } else { | |
| 3391 | + $query = new \WP_Query($args); | |
| 3392 | + $all_ids = $query->posts; | |
| 3155 | 3393 | } |
| 3156 | - | |
| 3157 | - $query = new \WP_Query($args); | |
| 3158 | - $all_ids = $query->posts; | |
| 3159 | 3394 | $total = count($all_ids); |
| 3160 | 3395 | $pages = max(1, ceil($total / $per_page)); |
| 3161 | 3396 | $page = min($page, $pages); |
| 3162 | 3397 | $offset = ($page - 1) * $per_page; |