options = get_option('mxchat_options', array());
// AJAX hooks (admin only, but wp_ajax_ prefix ensures that)
add_action('wp_ajax_mxchat_generate_content', array($this, 'handle_generate_content'));
add_action('wp_ajax_mxchat_content_edit', array($this, 'handle_content_edit'));
add_action('wp_ajax_mxchat_content_progress', array($this, 'handle_content_progress'));
add_action('wp_ajax_mxchat_save_content_setting', array($this, 'handle_save_content_setting'));
add_action('wp_ajax_mxchat_content_history', array($this, 'handle_content_history'));
add_action('wp_ajax_mxchat_load_post_for_edit', array($this, 'handle_load_post_for_edit'));
add_action('wp_ajax_mxchat_delete_content', array($this, 'handle_delete_content'));
add_action('wp_ajax_mxchat_update_post_status', array($this, 'handle_update_post_status'));
// Background generation via loopback (nopriv because loopback doesn't carry cookies — auth via secret token)
add_action('wp_ajax_nopriv_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
add_action('wp_ajax_mxchat_generate_content_background', array($this, 'handle_generate_content_background'));
// Frontend CSS injection — outputs generated styles in
add_action('wp_head', array($this, 'inject_generated_css'));
// Hide admin bar inside content generator preview iframe (WordPress-native approach)
add_filter('show_admin_bar', array($this, 'hide_admin_bar_in_preview'));
// Disable wpautop and wptexturize for generated content.
// wpautop inserts rogue tags between block elements (section, div, etc.)
// which breaks flex/grid layouts. wptexturize converts quotes inside CSS
// values and data attributes into curly quotes, breaking functionality.
add_filter('the_content', array($this, 'protect_generated_content'), 1);
add_filter('the_content', array($this, 'restore_content_filters'), 999);
}
// ─── Content Filter Protection ──────────────────────────────────
/**
* Disable wpautop and wptexturize for MxChat-generated posts.
*
* WordPress applies these filters to the_content by default:
* - wpautop: wraps text in
tags, breaking flex/grid layouts
* - wptexturize: converts quotes to curly quotes, breaking attributes
*
* This runs at priority 1 (earliest) to remove the filters before
* they execute, then restore_content_filters() at priority 999
* re-adds them for subsequent posts (e.g. in archive/loop contexts).
*/
public function protect_generated_content($content) {
$post_id = get_the_ID();
if (!$post_id) {
return $content;
}
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
return $content;
}
// Remove wpautop and wptexturize for this post
remove_filter('the_content', 'wpautop');
remove_filter('the_content', 'wptexturize');
return $content;
}
/**
* Re-add wpautop and wptexturize after our generated content has rendered.
* This ensures other posts in the same page load (archives, widgets)
* still get normal WordPress formatting.
*/
public function restore_content_filters($content) {
if (!has_filter('the_content', 'wpautop')) {
add_filter('the_content', 'wpautop');
}
if (!has_filter('the_content', 'wptexturize')) {
add_filter('the_content', 'wptexturize');
}
return $content;
}
// ─── Frontend CSS Injection ──────────────────────────────────────
/**
* Inject generated CSS into
on the frontend.
* Fires on wp_head for any singular post/page that was created
* by the content generator. CSS is stored in post meta to avoid
* issues with wp_kses, wpautop, and wptexturize mangling styles
* stored in post_content.
*/
public function inject_generated_css() {
if (!is_singular()) {
return;
}
$post_id = get_the_ID();
if (!$post_id) {
return;
}
// Only inject on MxChat-generated posts
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
return;
}
$ai_css = get_post_meta($post_id, '_mxchat_content_css', true);
$fullwidth = get_post_meta($post_id, '_mxchat_fullwidth', true) === '1';
$hide_title = get_post_meta($post_id, '_mxchat_hide_title', true) === '1';
// Output the combined CSS
echo $this->get_generated_css($fullwidth, $ai_css);
// Hide the WordPress post/page title if configured.
// Covers: generic WP, Astra, GeneratePress, Kadence, OceanWP, Neve,
// Hello Elementor, Bricks, Divi, Blocksy, and common starter themes.
if ($hide_title) {
echo '' . "\n";
}
}
/**
* Hide the WordPress admin bar when the page is loaded inside the
* content generator preview iframe (?mxchat_preview=1).
*
* Uses the native show_admin_bar filter so WordPress never renders
* the bar at all — no CSS hacks, no flash of the bar disappearing.
*
* @param bool $show Whether to show the admin bar.
* @return bool
*/
public function hide_admin_bar_in_preview($show) {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag, no data processing
if (isset($_GET['mxchat_preview'])) {
return false;
}
return $show;
}
// ─── HTML Sanitization ────────────────────────────────────────────
/**
* Sanitize AI-generated HTML allowing all safe CSS properties.
* WordPress wp_kses_post() strips most inline styles (display, flex,
* background, gradient, border-radius, box-shadow, etc.) which breaks
* modern page layouts. This method uses a permissive allowlist for
* admin-generated content only.
*/
private function sanitize_generated_html($html) {
$allowed = wp_kses_allowed_html('post');
// Tags that need full style support
$styled_tags = array(
'div', 'section', 'article', 'header', 'footer', 'main', 'nav', 'aside',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'span', 'a', 'figure',
'figcaption', 'img', 'ul', 'ol', 'li', 'blockquote', 'table', 'thead',
'tbody', 'tr', 'th', 'td', 'button', 'strong', 'em', 'br', 'hr',
'video', 'source', 'iframe', 'svg', 'path', 'circle', 'rect', 'line',
'polyline', 'polygon', 'g', 'defs', 'use', 'symbol', 'text',
);
foreach ($styled_tags as $tag) {
if (!isset($allowed[$tag])) {
$allowed[$tag] = array();
}
$allowed[$tag]['style'] = true;
$allowed[$tag]['class'] = true;
$allowed[$tag]['id'] = true;
}
// iframe attributes for video embeds (YouTube, Vimeo, etc.)
$allowed['iframe']['src'] = true;
$allowed['iframe']['width'] = true;
$allowed['iframe']['height'] = true;
$allowed['iframe']['frameborder'] = true;
$allowed['iframe']['allow'] = true;
$allowed['iframe']['allowfullscreen'] = true;
$allowed['iframe']['title'] = true;
$allowed['iframe']['loading'] = true;
// Ensure a/img have their needed attributes
$allowed['a']['href'] = true;
$allowed['a']['target'] = true;
$allowed['a']['rel'] = true;
$allowed['img']['src'] = true;
$allowed['img']['alt'] = true;
$allowed['img']['width'] = true;
$allowed['img']['height'] = true;
$allowed['img']['loading'] = true;
// SVG attributes for inline icons
foreach (array('svg', 'path', 'circle', 'rect', 'line', 'polyline', 'polygon', 'g') as $svg_tag) {
$allowed[$svg_tag]['xmlns'] = true;
$allowed[$svg_tag]['viewbox'] = true;
$allowed[$svg_tag]['fill'] = true;
$allowed[$svg_tag]['stroke'] = true;
$allowed[$svg_tag]['stroke-width'] = true;
$allowed[$svg_tag]['stroke-linecap'] = true;
$allowed[$svg_tag]['stroke-linejoin'] = true;
$allowed[$svg_tag]['d'] = true;
$allowed[$svg_tag]['cx'] = true;
$allowed[$svg_tag]['cy'] = true;
$allowed[$svg_tag]['r'] = true;
$allowed[$svg_tag]['x'] = true;
$allowed[$svg_tag]['y'] = true;
$allowed[$svg_tag]['width'] = true;
$allowed[$svg_tag]['height'] = true;
$allowed[$svg_tag]['points'] = true;
$allowed[$svg_tag]['x1'] = true;
$allowed[$svg_tag]['y1'] = true;
$allowed[$svg_tag]['x2'] = true;
$allowed[$svg_tag]['y2'] = true;
$allowed[$svg_tag]['transform'] = true;
}
// Allow all safe CSS properties via the safecss filter
add_filter('safe_style_css', array($this, 'allow_all_safe_css'));
$clean = wp_kses($html, $allowed);
remove_filter('safe_style_css', array($this, 'allow_all_safe_css'));
return $clean;
}
/**
* Expand the list of allowed CSS properties for generated content.
*/
public function allow_all_safe_css($styles) {
$extra = array(
'display', 'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink',
'flex-basis', 'justify-content', 'align-items', 'align-self', 'gap', 'order',
'grid', 'grid-template-columns', 'grid-template-rows', 'grid-column', 'grid-row',
'grid-gap', 'grid-area',
'position', 'top', 'right', 'bottom', 'left', 'z-index',
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
'background', 'background-color', 'background-image', 'background-size',
'background-position', 'background-repeat', 'background-attachment',
'border', 'border-top', 'border-right', 'border-bottom', 'border-left',
'border-radius', 'border-color', 'border-style', 'border-width',
'box-shadow', 'text-shadow',
'color', 'font-size', 'font-weight', 'font-style', 'font-family',
'line-height', 'letter-spacing', 'text-align', 'text-decoration', 'text-transform',
'vertical-align', 'white-space', 'word-break', 'overflow', 'overflow-x', 'overflow-y',
'opacity', 'visibility', 'cursor',
'transition', 'transform', 'animation',
'object-fit', 'object-position',
'list-style', 'list-style-type',
'aspect-ratio',
);
return array_unique(array_merge($styles, $extra));
}
// ─── Generation Pipeline ───────────────────────────────────────────
/**
* Main content generation handler
*/
public function handle_generate_content() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$prompt = sanitize_textarea_field($_POST['prompt'] ?? '');
$content_type = sanitize_text_field($_POST['content_type'] ?? 'post');
$post_status = sanitize_text_field($_POST['post_status'] ?? 'draft');
$schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
$layout = sanitize_text_field($_POST['layout'] ?? 'fullwidth');
$title_display = sanitize_text_field($_POST['title_display'] ?? 'hide');
if (empty($prompt)) {
wp_send_json_error(array('message' => __('Please enter a prompt.', 'mxchat')));
}
// Validate inputs
if (!in_array($content_type, array('post', 'page'), true)) {
$content_type = 'post';
}
if (!in_array($post_status, array('draft', 'publish', 'future'), true)) {
$post_status = 'draft';
}
// Generate a unique progress key and secret for the background worker
$progress_key = 'mxchat_content_progress_' . get_current_user_id() . '_' . time();
$secret = wp_generate_password(32, false);
$this->update_progress($progress_key, 'starting', __('Starting generation...', 'mxchat'), 5);
// Store generation parameters for the background worker
set_transient($progress_key . '_params', array(
'secret' => $secret,
'user_id' => get_current_user_id(),
'prompt' => $prompt,
'content_type' => $content_type,
'post_status' => $post_status,
'schedule_date' => $schedule_date,
'layout' => $layout,
'title_display' => $title_display,
), 600); // 10 minute TTL
// Spawn background generation via non-blocking loopback request.
// This lets the current request end immediately so the browser gets
// the progress_key back in <1 second, avoiding Cloudflare 524 timeouts.
wp_remote_post(admin_url('admin-ajax.php'), array(
'timeout' => 0.01,
'blocking' => false,
'sslverify' => apply_filters('https_local_ssl_verify', false),
'body' => array(
'action' => 'mxchat_generate_content_background',
'progress_key' => $progress_key,
'secret' => $secret,
),
));
// Return immediately — the browser starts polling for progress
wp_send_json_success(array('progress_key' => $progress_key));
}
/**
* Background content generation handler.
* Called via non-blocking loopback from handle_generate_content().
* Authenticated via secret token stored in transient (no nonce/cookie needed).
*/
public function handle_generate_content_background() {
$progress_key = sanitize_text_field($_POST['progress_key'] ?? '');
$secret = sanitize_text_field($_POST['secret'] ?? '');
if (empty($progress_key) || empty($secret)) {
die();
}
// Validate the secret token
$params = get_transient($progress_key . '_params');
if (!$params || !isset($params['secret']) || $params['secret'] !== $secret) {
die();
}
// One-time use — delete params transient
delete_transient($progress_key . '_params');
// Set up execution environment
if (function_exists('set_time_limit')) {
set_time_limit(300);
}
ignore_user_abort(true);
// Restore the original user context
wp_set_current_user($params['user_id']);
$prompt = $params['prompt'];
$content_type = $params['content_type'];
$post_status = $params['post_status'];
$schedule_date = $params['schedule_date'];
$layout = $params['layout'];
$title_display = $params['title_display'];
$this->update_progress($progress_key, 'planning', __('Planning content structure...', 'mxchat'), 10);
// Step 1: Plan the content
$plan = $this->plan_content($prompt, $content_type);
if (is_wp_error($plan)) {
$this->update_progress($progress_key, 'error', $plan->get_error_message(), 0);
die();
}
$this->update_progress($progress_key, 'images', __('Generating images...', 'mxchat'), 30);
// Step 2: Generate images
$image_urls = $this->generate_content_images($plan, $progress_key);
$this->update_progress($progress_key, 'writing', __('Writing full content...', 'mxchat'), 60);
// Step 3: Generate full HTML content
$html_content = $this->generate_html_content($plan, $image_urls, $content_type, $prompt);
if (is_wp_error($html_content)) {
$this->update_progress($progress_key, 'error', $html_content->get_error_message(), 0);
die();
}
$this->update_progress($progress_key, 'creating', __('Creating WordPress post...', 'mxchat'), 85);
// Step 4: Create the WordPress post/page
// Extract AI CSS before sanitization — stored in post meta, injected via wp_head
$ai_css = $this->extract_css($html_content);
$html_without_style = preg_replace('/\n";
}
$full_content_for_ai .= $current_content;
$user_message = "Current post title: " . $current_title . "\n\nCurrent content (style block + HTML):\n" . $full_content_for_ai . "\n\nUser edit request: " . $edit_instruction;
$messages = array(
array('role' => 'user', 'content' => $user_message),
);
// Allow add-ons to handle the edit via tool calling (str_replace, etc.)
// Filter returns null to fall through to the default full-rewrite approach.
$result = apply_filters('mxchat_content_tool_edit', null, $full_content_for_ai, $edit_instruction, $current_title);
if ($result === null) {
// Default: AI rewrites entire page
$result = $this->call_content_model($system_prompt, $messages, 16384);
}
if (is_wp_error($result)) {
wp_send_json_error(array('message' => $result->get_error_message()));
}
// Clean up code fences if present
$result = trim($result);
$result = preg_replace('/^```(?:html)?\s*/i', '', $result);
$result = preg_replace('/\s*```\s*$/', '', $result);
// Strip HTML comments
$result = preg_replace('//s', '', $result);
// Extract title if it was changed (look for first h1)
$new_title = $current_title;
if (preg_match('/]*>(.*?)<\/h1>/is', $result, $title_match)) {
$new_title = wp_strip_all_tags($title_match[1]);
}
// Extract CSS → meta, sanitize HTML → post_content
$ai_css = $this->extract_css($result);
$html_without_style = preg_replace('/ block ready to prepend to post content.
*/
private function get_generated_css($fullwidth = true, $ai_css = '') {
$css = '';
return $css;
}
/**
* Apply layout settings via theme-specific and builder-specific post meta.
*
* Supports: Astra, GeneratePress, Kadence, OceanWP, Neve, Blocksy,
* Elementor, Bricks Builder, Divi, Beaver Builder, and generic WordPress.
*
* Page builders that are installed but NOT used to edit this post will
* still respect standard WordPress post_content — this method sets the
* right meta so the theme renders it fullwidth without sidebar.
*/
private function apply_layout_settings($post_id, $layout, $title_display) {
$is_fullwidth = ($layout === 'fullwidth');
$hide_title = ($title_display === 'hide');
// ── Astra Theme ──
if (defined('ASTRA_THEME_VERSION') || get_template() === 'astra') {
if ($is_fullwidth) {
update_post_meta($post_id, 'site-content-layout', 'page-builder');
update_post_meta($post_id, 'site-sidebar-layout', 'no-sidebar');
}
if ($hide_title) {
update_post_meta($post_id, 'site-post-title', 'disabled');
}
}
// ── GeneratePress Theme ──
if (defined('GENERATE_VERSION') || get_template() === 'generatepress') {
if ($is_fullwidth) {
update_post_meta($post_id, '_generate-sidebar-layout-meta', 'no-sidebar');
update_post_meta($post_id, '_generate-full-width-content', 'true');
}
if ($hide_title) {
update_post_meta($post_id, '_generate-disable-title', 'true');
}
}
// ── Kadence Theme ──
if (class_exists('Kadence\\Theme') || get_template() === 'kadence') {
if ($is_fullwidth) {
update_post_meta($post_id, '_kad_post_layout', 'fullwidth');
update_post_meta($post_id, '_kad_post_content_style', 'unboxed');
}
if ($hide_title) {
update_post_meta($post_id, '_kad_post_title', 'hide');
}
}
// ── OceanWP Theme ──
if (class_exists('Ocean_Extra') || get_template() === 'oceanwp') {
if ($is_fullwidth) {
update_post_meta($post_id, 'oceanwp_post_layout', 'full-width');
update_post_meta($post_id, 'ocean_content_layout', 'full-width');
}
if ($hide_title) {
update_post_meta($post_id, 'oceanwp_disable_title', 'on');
}
}
// ── Neve Theme ──
if (get_template() === 'neve') {
if ($is_fullwidth) {
update_post_meta($post_id, 'neve_meta_sidebar', 'full-width');
update_post_meta($post_id, 'neve_meta_container', 'full-width');
}
if ($hide_title) {
update_post_meta($post_id, 'neve_meta_disable_title', 'on');
}
}
// ── Blocksy Theme ──
if (get_template() === 'blocksy') {
if ($is_fullwidth) {
update_post_meta($post_id, 'page_structure_type', 'type-4');
}
if ($hide_title) {
update_post_meta($post_id, 'disable_header', 'yes');
}
}
// ── Elementor Canvas/Full Width ──
// When Elementor is installed, use its Canvas template for the cleanest
// fullwidth output (no header/footer/sidebar chrome from the theme).
// The post still uses standard post_content — Elementor only takes over
// rendering when _elementor_edit_mode is set (which we don't set).
if (defined('ELEMENTOR_VERSION') && $is_fullwidth) {
$post_type = get_post_type($post_id);
$templates = wp_get_theme()->get_page_templates(get_post($post_id), $post_type);
// Prefer Elementor Canvas (no theme chrome at all)
if (isset($templates['elementor_canvas'])) {
update_post_meta($post_id, '_wp_page_template', 'elementor_canvas');
} elseif (isset($templates['elementor_header_footer'])) {
update_post_meta($post_id, '_wp_page_template', 'elementor_header_footer');
}
}
// ── Divi Theme / Divi Builder ──
if (defined('ET_BUILDER_VERSION') || get_template() === 'Divi') {
if ($is_fullwidth) {
update_post_meta($post_id, '_et_pb_page_layout', 'et_full_width_page');
update_post_meta($post_id, '_et_pb_side_nav', 'off');
}
if ($hide_title) {
update_post_meta($post_id, '_et_pb_show_title', 'off');
}
}
// ── Beaver Builder ──
if (class_exists('FLBuilder') || class_exists('FLBuilderLoader')) {
if ($is_fullwidth) {
// Beaver Themer uses this meta for sidebar control
update_post_meta($post_id, '_fl_builder_sidebar', 'no_sidebar');
}
}
// ── Bricks Builder ──
// Bricks uses its own rendering when _bricks_editor_mode is set.
// For standard WP content, it falls through to the theme's template.
// No special meta needed — our CSS resets and wp_head injection handle it.
// ── Generic full-width page template ──
// Only set _wp_page_template if the theme actually has a matching template file
// AND we haven't already set one above (e.g. Elementor Canvas).
// Setting a non-existent template causes "Invalid page template" errors on wp_update_post().
// Our CSS injection via wp_head already handles fullwidth layout for all themes.
if ($is_fullwidth) {
$current_template = get_post_meta($post_id, '_wp_page_template', true);
if (empty($current_template) || $current_template === 'default') {
$theme_templates = wp_get_theme()->get_page_templates(get_post($post_id));
foreach ($theme_templates as $file => $label) {
if (stripos($file, 'full') !== false && stripos($file, 'width') !== false) {
update_post_meta($post_id, '_wp_page_template', $file);
break;
}
}
}
}
}
// ─── SEO Metadata ──────────────────────────────────────────────────
/**
* Step 5: Fill SEO metadata for the generated post
*/
private function fill_seo_metadata($post_id, $plan) {
$meta_description = $plan['meta_description'] ?? '';
$keywords = $plan['keywords'] ?? array();
$focus_keyword = !empty($keywords) ? $keywords[0] : '';
$keywords_string = implode(', ', $keywords);
// Always store in custom meta as fallback
update_post_meta($post_id, '_mxchat_meta_description', sanitize_text_field($meta_description));
update_post_meta($post_id, '_mxchat_keywords', sanitize_text_field($keywords_string));
// Yoast SEO
if (defined('WPSEO_VERSION')) {
update_post_meta($post_id, '_yoast_wpseo_metadesc', sanitize_text_field($meta_description));
update_post_meta($post_id, '_yoast_wpseo_focuskw', sanitize_text_field($focus_keyword));
}
// RankMath
if (class_exists('RankMath')) {
update_post_meta($post_id, 'rank_math_description', sanitize_text_field($meta_description));
update_post_meta($post_id, 'rank_math_focus_keyword', sanitize_text_field($focus_keyword));
}
// All in One SEO
if (function_exists('aioseo')) {
update_post_meta($post_id, '_aioseo_description', sanitize_text_field($meta_description));
update_post_meta($post_id, '_aioseo_keywords', sanitize_text_field($keywords_string));
}
}
// ─── AI Model Caller ────────────────────────────────────────────────
/**
* Call the configured content model
*/
private function call_content_model($system_prompt, $messages, $max_tokens = 4096) {
$options = get_option('mxchat_options', array());
$model = $options['content_model'] ?? $options['model'] ?? 'gpt-5.1-chat-latest';
// Determine provider from model name
if ($this->is_claude_model($model)) {
return $this->call_claude($model, $options['claude_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
} elseif ($this->is_gemini_model($model)) {
return $this->call_gemini($model, $options['gemini_api_key'] ?? '', $system_prompt, $messages, $max_tokens);
} elseif ($this->is_xai_model($model)) {
return $this->call_openai_compatible($model, $options['xai_api_key'] ?? '', 'https://api.x.ai/v1/chat/completions', $system_prompt, $messages, $max_tokens);
} elseif ($this->is_deepseek_model($model)) {
return $this->call_openai_compatible($model, $options['deepseek_api_key'] ?? '', 'https://api.deepseek.com/chat/completions', $system_prompt, $messages, $max_tokens);
} else {
// Default: OpenAI
return $this->call_openai_compatible($model, $options['api_key'] ?? '', 'https://api.openai.com/v1/chat/completions', $system_prompt, $messages, $max_tokens);
}
}
/**
* Call OpenAI-compatible API (OpenAI, xAI, DeepSeek)
*/
private function call_openai_compatible($model, $api_key, $endpoint, $system_prompt, $messages, $max_tokens) {
if (empty($api_key)) {
return new WP_Error('no_api_key', __('API key not configured for the selected content model.', 'mxchat'));
}
$formatted = array();
$formatted[] = array('role' => 'system', 'content' => $system_prompt);
foreach ($messages as $msg) {
$formatted[] = array(
'role' => $msg['role'] ?? 'user',
'content' => $msg['content'] ?? '',
);
}
// GPT-5.x models require max_completion_tokens and only support temperature=1
$is_gpt5 = strpos($model, 'gpt-5') === 0;
$token_key = $is_gpt5 ? 'max_completion_tokens' : 'max_tokens';
$body = array(
'model' => $model,
'messages' => $formatted,
$token_key => $max_tokens,
'stream' => false,
);
if (!$is_gpt5) {
$body['temperature'] = 0.7;
}
// Add reasoning_effort only for GPT-5 models that support it
// gpt-5.2 and gpt-5.1-chat-latest don't support reasoning_effort parameter
if ($is_gpt5 && $model !== 'gpt-5.2' && $model !== 'gpt-5.1-chat-latest') {
// GPT-5.1 uses 'low' instead of 'minimal'
if ($model === 'gpt-5.1-2025-11-13') {
$body['reasoning_effort'] = 'low';
} else {
$body['reasoning_effort'] = 'minimal'; // For other GPT-5 models
}
}
// Scale timeout with token count — large generation calls need more time
$timeout = ($max_tokens > 8000) ? 300 : 120;
$response = wp_remote_post($endpoint, array(
'headers' => array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode($body),
'timeout' => $timeout,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if ($status_code !== 200) {
$error_msg = $decoded['error']['message'] ?? __('API request failed with status ', 'mxchat') . $status_code;
return new WP_Error('api_error', $error_msg);
}
if (isset($decoded['choices'][0]['message']['content'])) {
return trim($decoded['choices'][0]['message']['content']);
}
return new WP_Error('unexpected_response', __('Unexpected API response format.', 'mxchat'));
}
/**
* Call Claude (Anthropic) API
*/
private function call_claude($model, $api_key, $system_prompt, $messages, $max_tokens) {
if (empty($api_key)) {
return new WP_Error('no_api_key', __('Claude API key not configured.', 'mxchat'));
}
$formatted = array();
foreach ($messages as $msg) {
$role = $msg['role'] ?? 'user';
if (!in_array($role, array('user', 'assistant'), true)) {
$role = 'user';
}
$formatted[] = array(
'role' => $role,
'content' => $msg['content'] ?? '',
);
}
$body = array(
'model' => $model,
'max_tokens' => $max_tokens,
'temperature' => 0.7,
'messages' => $formatted,
'system' => $system_prompt,
);
$timeout = ($max_tokens > 8000) ? 300 : 120;
$response = wp_remote_post('https://api.anthropic.com/v1/messages', array(
'headers' => array(
'Content-Type' => 'application/json',
'x-api-key' => $api_key,
'anthropic-version' => '2023-06-01',
),
'body' => wp_json_encode($body),
'timeout' => $timeout,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if ($status_code !== 200) {
$error_msg = $decoded['error']['message'] ?? __('Claude API error ', 'mxchat') . $status_code;
return new WP_Error('claude_error', $error_msg);
}
if (isset($decoded['content'][0]['text'])) {
return trim($decoded['content'][0]['text']);
}
return new WP_Error('unexpected_response', __('Unexpected Claude response format.', 'mxchat'));
}
/**
* Call Gemini API
*/
private function call_gemini($model, $api_key, $system_prompt, $messages, $max_tokens) {
if (empty($api_key)) {
return new WP_Error('no_api_key', __('Gemini API key not configured.', 'mxchat'));
}
$formatted = array();
// System instructions as first user message
$formatted[] = array(
'role' => 'user',
'parts' => array(array('text' => "[System Instructions] " . $system_prompt)),
);
$formatted[] = array(
'role' => 'model',
'parts' => array(array('text' => "I understand and will follow these instructions.")),
);
foreach ($messages as $msg) {
$role = ($msg['role'] ?? 'user') === 'assistant' ? 'model' : 'user';
$formatted[] = array(
'role' => $role,
'parts' => array(array('text' => $msg['content'] ?? '')),
);
}
$body = array(
'contents' => $formatted,
'generationConfig' => array(
'temperature' => 0.7,
'topP' => 0.95,
'topK' => 40,
'maxOutputTokens' => $max_tokens,
),
);
$api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
$url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key={$api_key}";
$timeout = ($max_tokens > 8000) ? 300 : 120;
$response = wp_remote_post($url, array(
'headers' => array('Content-Type' => 'application/json'),
'body' => wp_json_encode($body),
'timeout' => $timeout,
));
if (is_wp_error($response)) {
return $response;
}
$status_code = wp_remote_retrieve_response_code($response);
$decoded = json_decode(wp_remote_retrieve_body($response), true);
if ($status_code !== 200) {
$error_msg = $decoded['error']['message'] ?? __('Gemini API error ', 'mxchat') . $status_code;
return new WP_Error('gemini_error', $error_msg);
}
if (isset($decoded['candidates'][0]['content']['parts'][0]['text'])) {
return trim($decoded['candidates'][0]['content']['parts'][0]['text']);
}
return new WP_Error('unexpected_response', __('Unexpected Gemini response format.', 'mxchat'));
}
// ─── Model Detection Helpers ────────────────────────────────────────
private function is_claude_model($model) {
return strpos($model, 'claude') === 0;
}
private function is_gemini_model($model) {
return strpos($model, 'gemini') === 0;
}
private function is_xai_model($model) {
return strpos($model, 'grok') === 0;
}
private function is_deepseek_model($model) {
return strpos($model, 'deepseek') === 0;
}
// ─── Content Settings Save ────────────────────────────────────────
/**
* Dedicated handler for saving content generator settings.
* Bypasses the main settings handler entirely.
*/
public function handle_save_content_setting() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$field = sanitize_text_field($_POST['field'] ?? '');
$value = sanitize_text_field($_POST['value'] ?? '');
$allowed_fields = array('content_model', 'content_image_model', 'content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use');
if (!in_array($field, $allowed_fields, true)) {
wp_send_json_error(array('message' => __('Invalid field.', 'mxchat')));
}
// Toggle fields
if (in_array($field, array('content_enable_images', 'content_use_placeholders', 'content_internal_linking', 'content_tool_use'), true)) {
$value = ($value === 'on') ? 'on' : 'off';
}
$options = get_option('mxchat_options', array());
$options[$field] = $value;
update_option('mxchat_options', $options);
wp_send_json_success(array('message' => __('Setting saved.', 'mxchat')));
}
// ─── Progress Tracking ─────────────────────────────────────────────
/**
* Update generation progress.
* Uses wp_options directly (not transients) to avoid object cache
* stale-read issues across the background worker and polling processes.
*/
private function update_progress($key, $step, $message, $percent, $result = null) {
global $wpdb;
$data = array(
'step' => $step,
'message' => $message,
'percent' => $percent,
'updated' => time(),
);
if ($result !== null) {
$data['result'] = $result;
}
$option_name = '_mxchat_progress_' . $key;
$serialized = maybe_serialize($data);
// Direct DB write — bypasses object cache entirely
$exists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM $wpdb->options WHERE option_name = %s",
$option_name
));
if ($exists) {
$wpdb->update(
$wpdb->options,
array('option_value' => $serialized),
array('option_name' => $option_name)
);
} else {
$wpdb->insert(
$wpdb->options,
array(
'option_name' => $option_name,
'option_value' => $serialized,
'autoload' => 'no',
)
);
}
// Also bust the object cache in case anything reads via get_option()
wp_cache_delete($option_name, 'options');
}
/**
* Read generation progress.
* Direct DB read — bypasses object cache for guaranteed freshness.
*/
private function get_progress($key) {
global $wpdb;
$option_name = '_mxchat_progress_' . $key;
$value = $wpdb->get_var($wpdb->prepare(
"SELECT option_value FROM $wpdb->options WHERE option_name = %s",
$option_name
));
if ($value === null) {
return false;
}
return maybe_unserialize($value);
}
/**
* Delete generation progress (cleanup).
*/
private function delete_progress($key) {
global $wpdb;
$option_name = '_mxchat_progress_' . $key;
$wpdb->delete($wpdb->options, array('option_name' => $option_name));
wp_cache_delete($option_name, 'options');
}
// ─── Content History ──────────────────────────────────────────────
/**
* Return paginated list of AI-generated posts for the History tab.
*/
public function handle_content_history() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$page = max(1, intval($_POST['page'] ?? 1));
$per_page = 10;
$query = new WP_Query(array(
'post_type' => array('post', 'page'),
'post_status' => array('publish', 'draft', 'future', 'pending', 'private'),
'meta_key' => '_mxchat_generated',
'meta_value' => '1',
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'date',
'order' => 'DESC',
));
$items = array();
foreach ($query->posts as $post) {
$thumb = get_the_post_thumbnail_url($post->ID, 'thumbnail');
$items[] = array(
'post_id' => $post->ID,
'title' => $post->post_title,
'status' => $post->post_status,
'post_type' => $post->post_type,
'date' => get_the_date('M j, Y', $post),
'thumbnail' => $thumb ? $thumb : '',
'permalink' => get_permalink($post->ID),
);
}
wp_send_json_success(array(
'items' => $items,
'total' => (int) $query->found_posts,
'total_pages' => (int) $query->max_num_pages,
'current_page' => $page,
));
}
/**
* Move an AI-generated post to the trash.
*/
public function handle_delete_content() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$post_id = intval($_POST['post_id'] ?? 0);
if (!$post_id) {
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
}
// Only allow deleting MxChat-generated content
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
}
$result = wp_trash_post($post_id);
if (!$result) {
wp_send_json_error(array('message' => __('Failed to delete post.', 'mxchat')));
}
wp_send_json_success(array('post_id' => $post_id));
}
/**
* Update the status of an AI-generated post (draft, publish, future).
*/
public function handle_update_post_status() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$post_id = intval($_POST['post_id'] ?? 0);
$new_status = sanitize_text_field($_POST['new_status'] ?? '');
$schedule_date = sanitize_text_field($_POST['schedule_date'] ?? '');
if (!$post_id) {
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
}
// Only allow updating MxChat-generated content
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
}
if (!in_array($new_status, array('draft', 'publish', 'future'), true)) {
wp_send_json_error(array('message' => __('Invalid status.', 'mxchat')));
}
$post_args = array('ID' => $post_id, 'post_status' => $new_status);
// Scheduled: require future date
if ($new_status === 'future') {
if (empty($schedule_date)) {
wp_send_json_error(array('message' => __('A schedule date is required.', 'mxchat')));
}
$post_args['post_date'] = $schedule_date;
$post_args['post_date_gmt'] = get_gmt_from_date($schedule_date);
$post_args['edit_date'] = true;
}
// Transitioning FROM future to draft/publish: reset post_date to now
if ($new_status !== 'future') {
$current = get_post($post_id);
if ($current && $current->post_status === 'future') {
$post_args['post_date'] = current_time('mysql');
$post_args['post_date_gmt'] = current_time('mysql', true);
$post_args['edit_date'] = true;
}
}
$result = wp_update_post($post_args, true);
if (is_wp_error($result)) {
wp_send_json_error(array('message' => $result->get_error_message()));
}
// Re-read to get confirmed status (WP may auto-publish if schedule date is past)
$updated = get_post($post_id);
wp_send_json_success(array(
'post_id' => $post_id,
'status' => $updated->post_status,
));
}
/**
* Load an existing AI-generated post into the editor state.
* Returns the same data shape as the generation success response.
*/
public function handle_load_post_for_edit() {
check_ajax_referer('mxchat_content_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => __('Unauthorized', 'mxchat')));
}
$post_id = intval($_POST['post_id'] ?? 0);
if (!$post_id) {
wp_send_json_error(array('message' => __('Invalid post ID.', 'mxchat')));
}
$post = get_post($post_id);
if (!$post) {
wp_send_json_error(array('message' => __('Post not found.', 'mxchat')));
}
if (get_post_meta($post_id, '_mxchat_generated', true) !== '1') {
wp_send_json_error(array('message' => __('This post was not created by the content generator.', 'mxchat')));
}
$preview_url = add_query_arg(array('preview' => 'true'), get_permalink($post_id));
$edit_url = admin_url('post.php?post=' . $post_id . '&action=edit');
$permalink = get_permalink($post_id);
// Discover images — prefer stored IDs, fall back to post_parent query
$images = $this->discover_post_images($post_id);
wp_send_json_success(array(
'post_id' => $post_id,
'preview_url' => $preview_url,
'edit_url' => $edit_url,
'permalink' => $permalink,
'title' => $post->post_title,
'status' => $post->post_status,
'images' => $images,
'meta' => array(
'description' => get_post_meta($post_id, '_mxchat_meta_description', true),
'keyword' => $this->get_focus_keyword($post_id),
'excerpt' => $post->post_excerpt,
),
));
}
/**
* Discover images associated with a generated post.
* Uses stored attachment IDs (most reliable), falls back to post_parent query.
*/
private function discover_post_images($post_id) {
$images = array();
$stored_ids = get_post_meta($post_id, '_mxchat_image_ids', true);
if (!empty($stored_ids) && is_array($stored_ids)) {
// Primary: use stored attachment IDs
foreach ($stored_ids as $att_id) {
$att_url = wp_get_attachment_url($att_id);
if ($att_url) {
$thumb_url = wp_get_attachment_image_url($att_id, 'medium');
$images[] = array(
'url' => $att_url,
'thumbnail' => $thumb_url ?: $att_url,
'attachment_id' => $att_id,
);
}
}
} else {
// Fallback: query by post_parent + meta key
$attachments = get_posts(array(
'post_type' => 'attachment',
'post_mime_type' => 'image',
'posts_per_page' => -1,
'post_parent' => $post_id,
'meta_key' => '_mxchat_image_prompt',
'meta_compare' => 'EXISTS',
'orderby' => 'date',
'order' => 'ASC',
));
foreach ($attachments as $att) {
$att_url = wp_get_attachment_url($att->ID);
if ($att_url) {
$thumb_url = wp_get_attachment_image_url($att->ID, 'medium');
$images[] = array(
'url' => $att_url,
'thumbnail' => $thumb_url ?: $att_url,
'attachment_id' => $att->ID,
);
}
}
}
// Fallback: include featured image if nothing else found
$featured_id = get_post_thumbnail_id($post_id);
if (empty($images) && $featured_id) {
$feat_url = wp_get_attachment_url($featured_id);
$feat_thumb = wp_get_attachment_image_url($featured_id, 'medium');
if ($feat_url) {
$images[] = array(
'url' => $feat_url,
'thumbnail' => $feat_thumb ?: $feat_url,
'attachment_id' => $featured_id,
);
}
}
return $images;
}
/**
* Get the focus keyword (first keyword from comma-separated list).
*/
private function get_focus_keyword($post_id) {
$keywords = get_post_meta($post_id, '_mxchat_keywords', true);
if (!empty($keywords)) {
$parts = explode(',', $keywords);
return trim($parts[0]);
}
return '';
}
}