Files
wp-agentic-writer/includes/class-gutenberg-sidebar.php
2026-01-28 00:26:00 +07:00

5433 lines
167 KiB
PHP

<?php
/**
* Gutenberg Sidebar
*
* Registers the plugin sidebar in Gutenberg editor.
*
* @package WP_Agentic_Writer
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Class WP_Agentic_Writer_Gutenberg_Sidebar
*
* @since 0.1.0
*/
class WP_Agentic_Writer_Gutenberg_Sidebar {
/**
* Get singleton instance.
*
* @since 0.1.0
* @return WP_Agentic_Writer_Gutenberg_Sidebar
*/
public static function get_instance() {
static $instance = null;
if ( null === $instance ) {
$instance = new self();
}
return $instance;
}
/**
* Constructor.
*
* @since 0.1.0
*/
private function __construct() {
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ) );
add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
}
/**
* Enqueue sidebar assets.
*
* @since 0.1.0
*/
public function enqueue_assets() {
// Check if Gutenberg is available.
if ( ! function_exists( 'register_block_type' ) ) {
return;
}
// Check if we're in the block editor.
$current_screen = get_current_screen();
if ( ! $current_screen || ! $current_screen->is_block_editor ) {
return;
}
// Build script URL.
$script_url = WP_AGENTIC_WRITER_URL . 'assets/js/sidebar.js';
$style_url = WP_AGENTIC_WRITER_URL . 'assets/css/sidebar.css';
$editor_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/editor.css';
$markdown_it_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it.min.js';
$dompurify_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/purify.min.js';
$markdown_task_lists_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it-task-lists.min.js';
// Debug: Log the script URL.
error_log( 'WP Agentic Writer - Script URL: ' . $script_url );
error_log( 'WP Agentic Writer - File exists: ' . ( file_exists( WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js' ) ? 'YES' : 'NO' ) );
// Enqueue markdown renderer and sanitizer.
wp_enqueue_script(
'wp-agentic-writer-markdown-it',
$markdown_it_url,
array(),
'13.0.2',
true
);
wp_enqueue_script(
'wp-agentic-writer-dompurify',
$dompurify_url,
array(),
'3.0.8',
true
);
wp_enqueue_script(
'wp-agentic-writer-markdown-task-lists',
$markdown_task_lists_url,
array( 'wp-agentic-writer-markdown-it' ),
'2.1.1',
true
);
// Enqueue sidebar script.
$script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js';
wp_enqueue_script(
'wp-agentic-writer-sidebar',
$script_url,
array(
'wp-plugins',
'wp-edit-post',
'wp-element',
'wp-components',
'wp-compose',
'wp-data',
'wp-i18n',
'wp-blocks',
'wp-agentic-writer-markdown-it',
'wp-agentic-writer-dompurify',
'wp-agentic-writer-markdown-task-lists',
),
file_exists( $script_path ) ? filemtime( $script_path ) : WP_AGENTIC_WRITER_VERSION,
true
);
$block_toolbar_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/block-refine.js';
wp_enqueue_script(
'wp-agentic-writer-block-chat-mention',
WP_AGENTIC_WRITER_URL . 'assets/js/block-refine.js',
array(
'wp-block-editor',
'wp-components',
'wp-compose',
'wp-data',
'wp-element',
'wp-hooks',
'wp-i18n',
),
file_exists( $block_toolbar_script_path ) ? filemtime( $block_toolbar_script_path ) : WP_AGENTIC_WRITER_VERSION,
true
);
// Enqueue sidebar styles.
$style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/sidebar.css';
wp_enqueue_style(
'wp-agentic-writer-sidebar',
$style_url,
array(),
file_exists( $style_path ) ? filemtime( $style_path ) : WP_AGENTIC_WRITER_VERSION
);
// Enqueue editor styles for image placeholders.
$editor_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/editor.css';
wp_enqueue_style(
'wp-agentic-writer-editor',
$editor_style_url,
array(),
file_exists( $editor_style_path ) ? filemtime( $editor_style_path ) : WP_AGENTIC_WRITER_VERSION
);
// Get current post ID.
$post_id = isset( $_GET['post'] ) ? intval( $_GET['post'] ) : 0;
if ( ! $post_id ) {
$post_id = get_the_ID();
}
if ( ! $post_id ) {
$post_id = 0;
}
// Get settings for JS.
$settings = $this->get_settings_for_js();
// Localize script with data.
$data = array(
'apiUrl' => rest_url( 'wp-agentic-writer/v1' ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'postId' => $post_id,
'settings' => $settings,
'version' => WP_AGENTIC_WRITER_VERSION,
'debug' => defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG,
'pluginUrl' => plugin_dir_url( dirname( __FILE__ ) ),
);
wp_localize_script( 'wp-agentic-writer-sidebar', 'wpAgenticWriter', $data );
}
/**
* Get settings for JavaScript.
*
* @since 0.1.0
* @return array Settings.
*/
private function get_settings_for_js() {
$settings = get_option( 'wp_agentic_writer_settings', array() );
// Don't expose API key to frontend.
unset( $settings['openrouter_api_key'] );
// Ensure all required keys exist with defaults (6 models per model-preset-brief.md).
$defaults = array(
'chat_model' => 'google/gemini-2.5-flash',
'clarity_model' => 'google/gemini-2.5-flash',
'planning_model' => 'google/gemini-2.5-flash',
'writing_model' => 'anthropic/claude-3.5-sonnet',
'refinement_model' => 'anthropic/claude-3.5-sonnet',
'image_model' => 'openai/gpt-4o',
'web_search_enabled' => false,
'search_engine' => 'auto',
'search_depth' => 'medium',
'cost_tracking_enabled' => true,
'monthly_budget' => 600,
'settings_url' => admin_url( 'options-general.php?page=wp-agentic-writer' ),
'preferred_languages' => array( 'auto', 'English', 'Indonesian' ),
'custom_languages' => array(),
);
return wp_parse_args( $settings, $defaults );
}
/**
* Register REST API routes.
*
* @since 0.1.0
*/
public function register_rest_routes() {
// Get models endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/models',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_models' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Refresh models endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/models/refresh',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_refresh_models' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Chat endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/chat',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_chat_request' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Clear chat context endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/clear-context',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_clear_context' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Chat history endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/chat-history/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_chat_history' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Post config endpoints.
register_rest_route(
'wp-agentic-writer/v1',
'/post-config/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_post_config' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/post-config/(?P<post_id>\d+)',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_update_post_config' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Generate plan endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/generate-plan',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_plan' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Revise plan endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/revise-plan',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_revise_plan' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Execute article endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/execute-article',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_execute_article' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Reformat blocks endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/reformat-blocks',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_reformat_blocks' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Regenerate block endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/regenerate-block',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_regenerate_block' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Check clarity endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/check-clarity',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_check_clarity' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Block refine endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/refine-block',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_block_refine' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Chat-based block refinement endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/refine-from-chat',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_refine_from_chat' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Section block mapping endpoints.
register_rest_route(
'wp-agentic-writer/v1',
'/section-blocks',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_save_section_blocks' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/section-blocks/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_section_blocks' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Get cost tracking data endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/cost-tracking/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_cost_tracking' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// SEO audit endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/seo-audit/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_seo_audit' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Generate meta description endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/generate-meta',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_meta' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Suggest keywords endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/suggest-keywords',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_suggest_keywords' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Summarize context endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/summarize-context',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_summarize_context' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
// Detect intent endpoint.
register_rest_route(
'wp-agentic-writer/v1',
'/detect-intent',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_detect_intent' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
}
/**
* Check permissions.
*
* @since 0.1.0
* @return bool True if user has permission.
*/
public function check_permissions() {
return current_user_can( 'edit_posts' );
}
/**
* Handle chat request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_chat_request( $request ) {
$params = $request->get_json_params();
$messages = $params['messages'] ?? array();
$post_id = $params['postId'] ?? 0;
$type = $params['type'] ?? 'planning';
$stream = ! empty( $params['stream'] );
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
// Detect language from user's last message for real-time response matching
$last_user_message = $this->get_last_user_message( $messages );
$detected_from_message = $this->detect_language_from_text( $last_user_message );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$effective_language = $this->resolve_language_preference( $post_config, $detected_from_message ?: $stored_language );
$language_instruction = $this->build_language_instruction( $effective_language, 'chat responses' );
$system_prompt = "You are a helpful writing assistant. Answer clearly, with concise structure and practical suggestions.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}";
$messages = $this->prepend_system_prompt( $messages, $system_prompt );
// Get OpenRouter provider.
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
if ( $stream ) {
$web_search_options = $this->get_web_search_options( $post_config );
$this->stream_chat_request( $messages, $post_id, $type, $web_search_options );
exit;
}
// Send chat request.
$response = $provider->chat( $messages, array(), $type );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'chat_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Track cost (always track for debugging, even if cost is 0).
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'chat',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
if ( ! empty( $response['content'] ) ) {
$this->update_post_chat_history( $post_id, $last_user_message, $response['content'] );
$this->update_post_memory(
$post_id,
array(
'last_prompt' => $last_user_message,
'last_intent' => 'chat',
)
);
}
return new WP_REST_Response( $response, 200 );
}
/**
* Stream chat request response.
*
* @since 0.1.0
* @param array $messages Chat messages.
* @param int $post_id Post ID.
* @param string $type Chat type.
* @param array $web_search_options Web search options.
* @return void
*/
private function stream_chat_request( $messages, $post_id, $type, $web_search_options = array() ) {
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' );
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$accumulated_content = '';
$total_cost = 0;
$response = $provider->chat_stream(
$messages,
$web_search_options,
$type,
function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content ) {
$accumulated_content = $full_content;
if ( '' !== $chunk ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational_stream',
'content' => $accumulated_content,
)
) . "\n\n";
flush();
}
}
);
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
$total_cost = $response['cost'] ?? 0;
// Debug: Log chat cost tracking
error_log( 'WP Agentic Writer: Tracking chat cost - post_id=' . $post_id . ', model=' . ($response['model'] ?? 'unknown') . ', type=' . $type . ', cost=' . $total_cost . ', input_tokens=' . ($response['input_tokens'] ?? 0) . ', output_tokens=' . ($response['output_tokens'] ?? 0) );
// Always track chat cost (even if 0 for debugging)
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'chat',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$total_cost
);
if ( ! empty( $accumulated_content ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational',
'content' => $accumulated_content,
)
) . "\n\n";
flush();
$last_user_message = $this->get_last_user_message( $messages );
$this->update_post_chat_history( $post_id, $last_user_message, $accumulated_content );
$this->update_post_memory(
$post_id,
array(
'last_prompt' => $last_user_message,
'last_intent' => 'chat',
)
);
}
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
)
) . "\n\n";
flush();
}
/**
* Clear chat context for a post.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_clear_context( $request ) {
$params = $request->get_json_params();
$post_id = intval( $params['postId'] ?? 0 );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
delete_post_meta( $post_id, '_wpaw_memory' );
delete_post_meta( $post_id, '_wpaw_chat_history' );
return new WP_REST_Response(
array(
'success' => true,
),
200
);
}
/**
* Get chat history for a post.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_chat_history( $request ) {
$post_id = intval( $request['post_id'] ?? 0 );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$history = $this->get_post_chat_history( $post_id );
return new WP_REST_Response(
array(
'messages' => $history,
),
200
);
}
/**
* Update per-post chat history.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @param string $user_message User message.
* @param string $assistant_message Assistant message.
* @return void
*/
private function update_post_chat_history( $post_id, $user_message, $assistant_message ) {
if ( $post_id <= 0 ) {
return;
}
$history = get_post_meta( $post_id, '_wpaw_chat_history', true );
if ( ! is_array( $history ) ) {
$history = array();
}
if ( $user_message ) {
$history[] = array(
'role' => 'user',
'content' => $user_message,
);
}
if ( $assistant_message ) {
$history[] = array(
'role' => 'assistant',
'content' => $assistant_message,
);
}
$settings = get_option( 'wp_agentic_writer_settings', array() );
$limit = isset( $settings['chat_history_limit'] ) ? absint( $settings['chat_history_limit'] ) : 20;
$limit = min( $limit, 200 );
if ( 0 === $limit ) {
update_post_meta( $post_id, '_wpaw_chat_history', array() );
return;
}
$history = array_slice( $history, -$limit );
update_post_meta( $post_id, '_wpaw_chat_history', $history );
}
/**
* Get per-post chat history.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @return array
*/
private function get_post_chat_history( $post_id ) {
if ( $post_id <= 0 ) {
return array();
}
$history = get_post_meta( $post_id, '_wpaw_chat_history', true );
if ( ! is_array( $history ) ) {
return array();
}
return array_values( $history );
}
/**
* Get default per-post configuration values.
*
* @since 0.1.0
* @return array
*/
private function get_default_post_config() {
$settings = get_option( 'wp_agentic_writer_settings', array() );
return array(
'article_length' => 'medium',
'language' => 'auto',
'tone' => '',
'audience' => '',
'experience_level'=> 'general',
'include_images' => true,
'web_search' => isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled'],
'default_mode' => 'writing',
// SEO fields
'seo_focus_keyword' => '',
'seo_secondary_keywords' => '',
'seo_meta_description' => '',
'seo_enabled' => true,
);
}
/**
* Sanitize post config input.
*
* @since 0.1.0
* @param array $config Post config.
* @return array
*/
private function sanitize_post_config( $config ) {
$defaults = $this->get_default_post_config();
$config = is_array( $config ) ? $config : array();
$sanitized = array();
$allowed_lengths = array( 'short', 'medium', 'long' );
$length = $config['article_length'] ?? $defaults['article_length'];
$sanitized['article_length'] = in_array( $length, $allowed_lengths, true ) ? $length : $defaults['article_length'];
// Validate language - normalize to lowercase for comparison
$settings = get_option( 'wp_agentic_writer_settings', array() );
$allowed_languages = array_merge(
$settings['preferred_languages'] ?? array( 'auto', 'English', 'Indonesian' ),
$settings['custom_languages'] ?? array()
);
// Normalize allowed languages to lowercase
$allowed_languages_lower = array_map( 'strtolower', $allowed_languages );
$language = strtolower( $config['language'] ?? $defaults['language'] );
$sanitized['language'] = in_array( $language, $allowed_languages_lower, true ) ? $language : 'auto';
$sanitized['tone'] = sanitize_text_field( $config['tone'] ?? $defaults['tone'] );
$sanitized['audience'] = sanitize_text_field( $config['audience'] ?? $defaults['audience'] );
$sanitized['experience_level'] = sanitize_text_field( $config['experience_level'] ?? $defaults['experience_level'] );
$sanitized['include_images'] = isset( $config['include_images'] )
? (bool) $config['include_images']
: (bool) $defaults['include_images'];
$sanitized['web_search'] = isset( $config['web_search'] )
? (bool) $config['web_search']
: (bool) $defaults['web_search'];
$allowed_modes = array( 'writing', 'planning', 'chat' );
$mode = $config['default_mode'] ?? $defaults['default_mode'];
$sanitized['default_mode'] = in_array( $mode, $allowed_modes, true ) ? $mode : $defaults['default_mode'];
// SEO fields
$sanitized['seo_focus_keyword'] = sanitize_text_field( $config['seo_focus_keyword'] ?? $defaults['seo_focus_keyword'] );
$sanitized['seo_secondary_keywords'] = sanitize_text_field( $config['seo_secondary_keywords'] ?? $defaults['seo_secondary_keywords'] );
$sanitized['seo_meta_description'] = sanitize_textarea_field( $config['seo_meta_description'] ?? $defaults['seo_meta_description'] );
$sanitized['seo_enabled'] = isset( $config['seo_enabled'] )
? (bool) $config['seo_enabled']
: (bool) $defaults['seo_enabled'];
return $sanitized;
}
/**
* Get post config (merged with defaults).
*
* @since 0.1.0
* @param int $post_id Post ID.
* @return array
*/
private function get_post_config( $post_id ) {
$defaults = $this->get_default_post_config();
if ( $post_id <= 0 ) {
return $defaults;
}
$stored = get_post_meta( $post_id, '_wpaw_post_config', true );
$stored = is_array( $stored ) ? $stored : array();
return $this->sanitize_post_config( wp_parse_args( $stored, $defaults ) );
}
/**
* Resolve post config from request, falling back to stored config.
*
* @since 0.1.0
* @param array $params Request params.
* @param int $post_id Post ID.
* @return array
*/
private function resolve_post_config_from_request( $params, $post_id ) {
if ( isset( $params['postConfig'] ) && is_array( $params['postConfig'] ) ) {
$merged = wp_parse_args( $params['postConfig'], $this->get_post_config( $post_id ) );
return $this->sanitize_post_config( $merged );
}
return $this->get_post_config( $post_id );
}
/**
* Build a short configuration context string for prompts.
*
* @since 0.1.0
* @param array $post_config Post config.
* @return string
*/
private function build_post_config_context( $post_config ) {
$lines = array();
if ( ! empty( $post_config['tone'] ) ) {
$lines[] = 'Tone: ' . $post_config['tone'];
}
if ( ! empty( $post_config['audience'] ) ) {
$lines[] = 'Target audience: ' . $post_config['audience'];
}
if ( ! empty( $post_config['experience_level'] ) && 'general' !== $post_config['experience_level'] ) {
$lines[] = 'Expertise level: ' . $post_config['experience_level'];
}
// Add SEO context if enabled
$seo_context = $this->build_seo_context( $post_config );
if ( empty( $lines ) && empty( $seo_context ) ) {
return '';
}
$result = '';
if ( ! empty( $lines ) ) {
$result .= "\nPOST CONFIG:\n- " . implode( "\n- ", $lines ) . "\n";
}
if ( ! empty( $seo_context ) ) {
$result .= $seo_context;
}
return $result;
}
/**
* Build SEO context for prompts.
*
* @since 0.1.0
* @param array $post_config Post config.
* @return string SEO context string.
*/
private function build_seo_context( $post_config ) {
if ( empty( $post_config['seo_enabled'] ) ) {
return '';
}
$seo_lines = array();
if ( ! empty( $post_config['seo_focus_keyword'] ) ) {
$seo_lines[] = 'Focus keyword: "' . $post_config['seo_focus_keyword'] . '" - Include this keyword naturally in: title, first paragraph, at least 2-3 subheadings, and throughout the content (aim for 1-2% density)';
}
if ( ! empty( $post_config['seo_secondary_keywords'] ) ) {
$seo_lines[] = 'Secondary keywords: ' . $post_config['seo_secondary_keywords'] . ' - Sprinkle these throughout the content naturally';
}
if ( empty( $seo_lines ) ) {
return '';
}
return "\nSEO OPTIMIZATION:\n- " . implode( "\n- ", $seo_lines ) . "\n- Use descriptive, keyword-rich subheadings (H2, H3)\n- Write compelling meta-description-worthy opening paragraph\n- Include internal linking opportunities where relevant\n";
}
/**
* Detect language from text using common word patterns.
*
* @since 0.1.0
* @param string $text Text to analyze.
* @return string Detected language code.
*/
private function detect_language_from_text( $text ) {
$text = strtolower( $text );
// Indonesian indicators
$indonesian_words = array( 'yang', 'dan', 'untuk', 'dengan', 'ini', 'itu', 'dari', 'ke', 'di', 'pada', 'adalah', 'akan', 'sudah', 'bisa', 'harus', 'tidak', 'juga', 'atau', 'saya', 'apa', 'bagaimana', 'mengapa', 'kenapa', 'gimana', 'tolong', 'mohon', 'silakan', 'terima', 'kasih', 'selamat', 'pagi', 'siang', 'malam', 'artikel', 'tentang', 'topik', 'pembahasan', 'cara', 'membuat', 'menulis' );
$indonesian_count = 0;
foreach ( $indonesian_words as $word ) {
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) {
$indonesian_count++;
}
}
// Spanish indicators
$spanish_words = array( 'que', 'de', 'no', 'es', 'el', 'la', 'los', 'las', 'un', 'una', 'por', 'con', 'para', 'como', 'pero', 'más', 'este', 'esta', 'todo', 'también', 'puede', 'hacer', 'tiene', 'cuando', 'sobre', 'entre', 'después', 'antes', 'porque', 'cómo', 'qué', 'cuál' );
$spanish_count = 0;
foreach ( $spanish_words as $word ) {
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) {
$spanish_count++;
}
}
// French indicators
$french_words = array( 'le', 'la', 'les', 'de', 'du', 'des', 'un', 'une', 'et', 'est', 'que', 'qui', 'dans', 'pour', 'pas', 'sur', 'avec', 'ce', 'cette', 'sont', 'être', 'avoir', 'faire', 'comme', 'mais', 'ou', 'où', 'plus', 'tout', 'bien', 'aussi', 'peut', 'très', 'comment', 'pourquoi', 'quoi' );
$french_count = 0;
foreach ( $french_words as $word ) {
if ( preg_match( '/\b' . preg_quote( $word, '/' ) . '\b/u', $text ) ) {
$french_count++;
}
}
// Determine language with threshold
$threshold = 2;
if ( $indonesian_count >= $threshold && $indonesian_count > $spanish_count && $indonesian_count > $french_count ) {
return 'indonesian';
}
if ( $spanish_count >= $threshold && $spanish_count > $indonesian_count && $spanish_count > $french_count ) {
return 'spanish';
}
if ( $french_count >= $threshold && $french_count > $indonesian_count && $french_count > $spanish_count ) {
return 'french';
}
// Return empty string instead of 'auto' to allow fallback to stored language
return '';
}
/**
* Resolve effective language preference.
*
* @since 0.1.0
* @param array $post_config Post config.
* @param string $fallback Language to fall back to.
* @return string
*/
private function resolve_language_preference( $post_config, $fallback ) {
$language = strtolower( (string) ( $post_config['language'] ?? 'auto' ) );
if ( 'auto' !== $language && '' !== $language ) {
return $language;
}
// If fallback is provided and not empty, use it
if ( ! empty( $fallback ) && 'auto' !== $fallback ) {
return $fallback;
}
return 'english';
}
/**
* Build language instruction for prompts.
*
* @since 0.1.0
* @param string $language Language code.
* @param string $context Context label.
* @return string
*/
private function build_language_instruction( $language, $context = 'content' ) {
$language = trim( (string) $language );
// If auto or empty, let AI detect from context
if ( empty( $language ) || 'auto' === strtolower( $language ) ) {
return "Write the {$context} in the most appropriate language based on the topic and context.";
}
// Pass any language name directly to AI - AI models understand all languages
return "You MUST write the {$context} in {$language}. Use native {$language} vocabulary, grammar, and style.";
}
/**
* Prepend a system prompt to messages.
*
* @since 0.1.0
* @param array $messages Messages list.
* @param string $prompt System prompt.
* @return array
*/
private function prepend_system_prompt( $messages, $prompt ) {
if ( empty( $prompt ) ) {
return $messages;
}
$messages = is_array( $messages ) ? $messages : array();
array_unshift(
$messages,
array(
'role' => 'system',
'content' => $prompt,
)
);
return $messages;
}
/**
* Build web search option overrides.
*
* @since 0.1.0
* @param array $post_config Post config.
* @return array
*/
private function get_web_search_options( $post_config ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
return array(
'web_search_enabled' => isset( $post_config['web_search'] ) ? (bool) $post_config['web_search'] : false,
'search_depth' => $settings['search_depth'] ?? 'medium',
'search_engine' => $settings['search_engine'] ?? 'auto',
);
}
/**
* Handle get post config request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_get_post_config( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
return new WP_REST_Response( $this->get_post_config( $post_id ), 200 );
}
/**
* Handle update post config request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error
*/
public function handle_update_post_config( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$params = $request->get_json_params();
$config = $this->sanitize_post_config( $params['postConfig'] ?? array() );
update_post_meta( $post_id, '_wpaw_post_config', $config );
return new WP_REST_Response( $config, 200 );
}
/**
* Get the last user message from a message list.
*
* @since 0.1.0
* @param array $messages Message list.
* @return string
*/
private function get_last_user_message( $messages ) {
if ( empty( $messages ) || ! is_array( $messages ) ) {
return '';
}
for ( $i = count( $messages ) - 1; $i >= 0; $i-- ) {
$message = $messages[ $i ];
if ( isset( $message['role'] ) && 'user' === $message['role'] && ! empty( $message['content'] ) ) {
return sanitize_text_field( $message['content'] );
}
}
return '';
}
/**
* Handle generate plan request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_generate_plan( $request ) {
$params = $request->get_json_params();
$topic = $params['topic'] ?? '';
$context = $params['context'] ?? '';
$post_id = $params['postId'] ?? 0;
$auto_execute = $params['autoExecute'] ?? false;
$stream = $params['stream'] ?? false;
$chat_history = $params['chatHistory'] ?? array();
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$article_length = $post_config['article_length'] ?? ( $params['articleLength'] ?? 'medium' );
$clarification_answers = $params['clarificationAnswers'] ?? array(); // Get clarification answers
$detected_language = $params['detectedLanguage'] ?? 'english'; // Get detected language from clarity check
$effective_language = $this->resolve_language_preference( $post_config, $detected_language );
$post_config_context = $this->build_post_config_context( $post_config );
$web_search_options = $this->get_web_search_options( $post_config );
if ( empty( $topic ) ) {
return new WP_Error(
'no_topic',
__( 'Topic is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Build chat history context for continuity.
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- CONVERSATION HISTORY ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION HISTORY ---\n";
$chat_history_context .= "\nUse the above conversation to understand what the user wants for this article.";
}
// If streaming is requested, use streaming response.
if ( $stream ) {
return $this->stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length, $clarification_answers, $effective_language, $post_config, $chat_history );
}
// Get OpenRouter provider.
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
// Build prompt for plan generation.
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
$system_prompt = "You are an expert content strategist and technical writer. Your task is to create a detailed article plan/outline based on the user's topic and context.
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
{$post_config_context}
Generate a JSON outline with the following structure:
{
\"title\": \"Article title\",
\"meta\": {
\"reading_time\": \"5 min\",
\"difficulty\": \"intermediate\",
\"cost_estimate\": 0.70
},
\"sections\": [
{
\"id\": \"unique-section-id\",
\"status\": \"pending\",
\"type\": \"section\",
\"heading\": \"Section heading\",
\"content\": [
{
\"type\": \"paragraph\",
\"content\": \"Brief description of what this section should cover\"
}
]
}
]
}
Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks.";
$memory_context = $this->get_post_memory_context( $post_id );
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nContext: {$context}{$post_config_context}{$memory_context}",
),
);
// Generate plan.
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'plan_generation_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Extract JSON from response.
$content = $response['content'];
$plan_json = $this->extract_json( $content );
if ( null === $plan_json ) {
return new WP_Error(
'invalid_json',
__( 'Failed to generate valid plan JSON.', 'wp-agentic-writer' ),
array( 'status' => 500 )
);
}
$plan_json = $this->ensure_plan_sections_with_tasks( $plan_json );
// Store plan in post meta.
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
update_post_meta( $post_id, '_wpaw_detected_language', $effective_language );
$summary = $this->build_memory_summary_from_plan( $plan_json );
$this->update_post_memory(
$post_id,
array(
'summary' => $summary,
'last_prompt' => $topic,
'last_intent' => 'generate',
)
);
}
// Track cost (always track for debugging).
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'planning',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
return new WP_REST_Response(
array(
'plan' => $plan_json,
'cost' => $response['cost'] ?? 0,
'web_search_results' => $response['web_search_results'] ?? array(),
),
200
);
}
/**
* Handle revise plan request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_revise_plan( $request ) {
$params = $request->get_json_params();
$instruction = $params['instruction'] ?? '';
$plan = $params['plan'] ?? array();
$post_id = $params['postId'] ?? 0;
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
$effective_language = $this->resolve_language_preference( $post_config, get_post_meta( $post_id, '_wpaw_detected_language', true ) );
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
$web_search_options = $this->get_web_search_options( $post_config );
if ( empty( $instruction ) ) {
return new WP_Error(
'no_instruction',
__( 'Instruction is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( empty( $plan ) || ! is_array( $plan ) ) {
return new WP_Error(
'no_plan',
__( 'Plan is required to revise.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$memory_context = $this->get_post_memory_context( $post_id );
$system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction.
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
{$post_config_context}
Return ONLY valid JSON in this structure:
{
\"title\": \"Article title\",
\"meta\": {
\"reading_time\": \"5 min\",
\"difficulty\": \"intermediate\",
\"cost_estimate\": 0.70
},
\"sections\": [
{
\"id\": \"unique-section-id\",
\"status\": \"pending\",
\"type\": \"section\",
\"heading\": \"Section heading\",
\"content\": [
{
\"type\": \"paragraph\",
\"content\": \"Brief description of what this section should cover\"
}
]
}
]
}
Rules:
- Preserve the JSON schema exactly.
- Preserve existing section id and status values when possible.
- New sections must include a new id and default status \"pending\".
- Edit only what is needed to satisfy the instruction.
- Keep headings as H2-level topics.
- No markdown, no explanation, JSON only.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Instruction: {$instruction}\n\nCurrent Outline JSON:\n" . wp_json_encode( $plan ) . $memory_context,
),
);
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.6 ), $web_search_options ), 'planning' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'plan_revision_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
$plan_json = $this->extract_json( $response['content'] );
if ( null === $plan_json ) {
return new WP_Error(
'plan_revision_invalid',
__( 'Failed to generate valid plan JSON.', 'wp-agentic-writer' ),
array( 'status' => 500 )
);
}
$plan_json = $this->ensure_plan_sections_with_tasks( $plan_json, $plan );
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
if ( ! empty( $effective_language ) ) {
update_post_meta( $post_id, '_wpaw_detected_language', $effective_language );
}
$summary = $this->build_memory_summary_from_plan( $plan_json );
$this->update_post_memory(
$post_id,
array(
'summary' => $summary,
'last_prompt' => $instruction,
'last_intent' => 'plan',
)
);
}
// Track cost (always track for debugging).
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'planning',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
return new WP_REST_Response(
array(
'plan' => $plan_json,
'cost' => $response['cost'] ?? 0,
),
200
);
}
/**
* Ensure plan sections have stable ids and task statuses.
*
* @since 0.1.0
* @param array $plan Plan data.
* @param array $previous_plan Previous plan for matching.
* @return array
*/
private function ensure_plan_sections_with_tasks( $plan, $previous_plan = array() ) {
if ( empty( $plan ) || ! is_array( $plan ) ) {
return $plan;
}
$sections = $plan['sections'] ?? array();
if ( ! is_array( $sections ) ) {
$sections = array();
}
$previous_sections = array();
if ( is_array( $previous_plan ) ) {
$previous_sections = $previous_plan['sections'] ?? array();
if ( ! is_array( $previous_sections ) ) {
$previous_sections = array();
}
}
$previous_by_id = array();
$previous_by_title = array();
foreach ( $previous_sections as $previous_section ) {
if ( ! is_array( $previous_section ) ) {
continue;
}
$previous_id = $previous_section['id'] ?? '';
$previous_title = $this->normalize_plan_section_title( $previous_section );
if ( $previous_id ) {
$previous_by_id[ $previous_id ] = $previous_section;
}
if ( $previous_title ) {
$previous_by_title[ $previous_title ] = $previous_section;
}
}
$normalized_sections = array();
foreach ( $sections as $section ) {
if ( ! is_array( $section ) ) {
continue;
}
$section_title = $this->normalize_plan_section_title( $section );
$section_id = $section['id'] ?? '';
$status = $section['status'] ?? '';
if ( $section_id && isset( $previous_by_id[ $section_id ] ) ) {
$matched = $previous_by_id[ $section_id ];
$status = $matched['status'] ?? $status;
} elseif ( $section_title && isset( $previous_by_title[ $section_title ] ) ) {
$matched = $previous_by_title[ $section_title ];
$section_id = $matched['id'] ?? $section_id;
$status = $matched['status'] ?? $status;
}
if ( empty( $section_id ) ) {
$section_id = wp_generate_uuid4();
}
if ( empty( $status ) ) {
$status = 'pending';
}
$section['id'] = $section_id;
$section['status'] = $status;
$normalized_sections[] = $section;
}
$plan['sections'] = $normalized_sections;
return $plan;
}
/**
* Normalize section title for matching.
*
* @since 0.1.0
* @param array $section Section data.
* @return string
*/
private function normalize_plan_section_title( $section ) {
$title = '';
if ( is_array( $section ) ) {
$title = $section['heading'] ?? $section['title'] ?? '';
}
$title = trim( wp_strip_all_tags( (string) $title ) );
return strtolower( $title );
}
/**
* Stream generate plan with optional auto-execution.
*
* @since 0.1.0
* @param string $topic Topic.
* @param string $context Context.
* @param int $post_id Post ID.
* @param bool $auto_execute Whether to auto-execute the article.
* @param string $article_length Article length (short, medium, or long).
* @return void Streams response to client.
*/
private function stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length = 'medium', $clarification_answers = array(), $detected_language = 'english', $post_config = array(), $chat_history = array() ) {
// Set headers for streaming.
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering.
// Flush output buffer to ensure immediate streaming.
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$total_cost = 0;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$web_search_options = $this->get_web_search_options( $post_config );
$effective_language = $this->resolve_language_preference( $post_config, $detected_language );
try {
// Note: Clarity check should be done BEFORE calling this streaming endpoint
// The frontend is responsible for checking clarity first via /check-clarity
// This endpoint only handles the actual streaming generation
// Send starting status
$this->send_status( 'starting', 'Connecting to AI...' );
// Step 1: Generate plan.
$this->send_status( 'planning', 'Creating article outline...' );
// Build clarification context if available.
$clarity_context = '';
if ( ! empty( $clarification_answers ) && is_array( $clarification_answers ) ) {
$clarity_context = "\n\n=== CONTEXT FROM CLARIFICATION QUIZ ===\n";
// Group by category.
$grouped = array();
foreach ( $clarification_answers as $answer ) {
$category = $answer['category'] ?? 'other';
$value = $answer['value'] ?? $answer['answer'] ?? '';
$skipped = $answer['skipped'] ?? false;
if ( ! $skipped && ! empty( $value ) ) {
$grouped[ $category ] = $value;
}
}
// Format for prompt.
$category_labels = array(
'target_outcome' => 'Primary Goal',
'target_audience' => 'Target Audience',
'tone' => 'Tone of Voice',
'content_depth' => 'Content Depth',
'expertise_level' => 'Expertise Level',
'content_type' => 'Content Type',
'pov' => 'Point of View',
);
foreach ( $grouped as $category => $value ) {
$label = $category_labels[ $category ] ?? ucwords( str_replace( '_', ' ', $category ) );
$clarity_context .= "- {$label}: {$value}\n";
}
$clarity_context .= "=== END CONTEXT ===\n";
}
// Build chat history context for continuity.
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- CONVERSATION HISTORY ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION HISTORY ---\n";
$chat_history_context .= "\nUse the above conversation to understand what the user wants for this article.";
}
// Add section limits based on article length.
$length_section_limits = array(
'short' => 'Create exactly 2-3 sections maximum.',
'medium' => 'Create 4-5 sections maximum.',
'long' => 'Create 6-8 sections maximum.',
);
$section_limit = $length_section_limits[ $article_length ];
// Determine language instruction for plan generation
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
$system_prompt = "You are an expert content strategist and technical writer. Your task is to create a detailed article plan/outline based on the user's topic and context.
CRITICAL LANGUAGE REQUIREMENT:
{$plan_language_instruction}
IMPORTANT CONSTRAINT: {$section_limit}
{$post_config_context}
Generate a JSON outline with the following structure:
{
\"title\": \"Article title\",
\"meta\": {
\"reading_time\": \"5 min\",
\"difficulty\": \"intermediate\",
\"cost_estimate\": 0.70
},
\"sections\": [
{
\"id\": \"unique-section-id\",
\"status\": \"pending\",
\"type\": \"section\",
\"heading\": \"Section heading\",
\"content\": [
{
\"type\": \"paragraph\",
\"content\": \"Brief description of what this section should cover\"
}
]
}
]
}
Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks.";
$memory_context = $this->get_post_memory_context( $post_id );
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nContext: {$context}{$chat_history_context}{$clarity_context}{$post_config_context}{$memory_context}",
),
);
// Log the request for debugging
error_log( 'WP Agentic Writer: Calling OpenRouter API for planning. Topic: ' . substr( $topic, 0, 100 ) );
error_log( 'WP Agentic Writer: Detected language: ' . $detected_language );
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' );
error_log( 'WP Agentic Writer: OpenRouter API response received' );
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
$content = $response['content'];
$plan_json = $this->extract_json( $content );
if ( null === $plan_json ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => 'Failed to generate valid plan JSON.',
)
) . "\n\n";
flush();
exit;
}
$plan_json = $this->ensure_plan_sections_with_tasks( $plan_json );
// Store plan in post meta.
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
update_post_meta( $post_id, '_wpaw_detected_language', $effective_language );
$summary = $this->build_memory_summary_from_plan( $plan_json );
$this->update_post_memory(
$post_id,
array(
'summary' => $summary,
'last_prompt' => $topic,
'last_intent' => 'generate',
)
);
}
$total_cost += $response['cost'];
// Track plan cost.
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'],
'planning',
$response['input_tokens'],
$response['output_tokens'],
$response['cost']
);
// Send plan data.
echo "data: " . wp_json_encode(
array(
'type' => 'plan',
'plan' => $plan_json,
'cost' => $response['cost'],
'web_search_results' => $response['web_search_results'] ?? array(),
)
) . "\n\n";
flush();
// Send plan complete status
if ( $auto_execute ) {
$this->send_status( 'plan_complete', 'Outline created! Starting to write...' );
} else {
$this->send_status( 'plan_complete', 'Outline ready.' );
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
)
) . "\n\n";
flush();
}
// Step 2: Auto-execute if requested.
if ( $auto_execute && ! empty( $plan_json['sections'] ) ) {
// Define length constraints with section counts
$length_constraints = array(
'short' => 'Write exactly 2-3 main sections. Each section should have 3-4 substantial paragraphs (4-6 sentences each). Go deep into each point with examples and explanations. Total: ~400 words.',
'medium' => 'Write 4-5 main sections. Each section should have 2-3 meaningful paragraphs (3-5 sentences each). Balance breadth with adequate depth. Total: ~750 words.',
'long' => 'Write 6-8 main sections. Each section should have 2-3 paragraphs (3-4 sentences each) with detailed examples and comprehensive coverage. Total: ~1500 words.',
);
$depth_instruction = array(
'short' => 'CRITICAL: Fewer sections, more depth per section. Avoid skimming. Each section should feel complete and comprehensive.',
'medium' => 'Balance: Moderate sections with good paragraph development. Each point should be explained with at least one example.',
'long' => 'Comprehensive: More sections covering all aspects, but still maintain substance in each paragraph.',
);
$length_instruction = $length_constraints[ $article_length ];
// Set post title from plan title with validation
if ( $post_id > 0 && ! empty( $plan_json['title'] ) ) {
// Verify post exists and user can edit
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) ) {
// Disable revisions during this update
add_filter( 'wp_revisions_to_keep', '__return_zero', 999 );
// Update post title
$update_result = wp_update_post(
array(
'ID' => $post_id,
'post_title' => sanitize_text_field( $plan_json['title'] ),
),
true // Return WP_Error on failure
);
if ( is_wp_error( $update_result ) ) {
error_log( 'WP Agentic Writer: Failed to update post title - ' . $update_result->get_error_message() );
}
// Restore filters
remove_filter( 'wp_revisions_to_keep', '__return_zero', 999 );
// Send title update to frontend for immediate sync
echo "data: " . wp_json_encode(
array(
'type' => 'title_update',
'title' => $plan_json['title'],
)
) . "\n\n";
flush();
}
}
// Determine language instruction based on detected language
$language_instruction = $this->build_language_instruction( $effective_language, 'ENTIRE article (conversational responses and article text)' );
$image_instruction = "IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Maximum 1-2 image suggestions per section";
if ( empty( $post_config['include_images'] ) ) {
$image_instruction = "IMAGE SUGGESTIONS:
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
}
$system_prompt = "You are an expert content writer and technical consultant. Your task is to provide helpful conversational feedback AND write the article content based on the provided plan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
ARTICLE LENGTH CONSTRAINT: {$length_instruction}
DEPTH GUIDELINE: {$depth_instruction[$article_length]}
{$post_config_context}
CRITICAL WRITING RULES:
1. LANGUAGE: Strictly follow the language requirement above. This is NON-NEGOTIABLE.
2. Section Count: Strictly follow the section count specified above
3. Paragraph Quality: Each paragraph must be 4-6 sentences with substance
4. No \"fluff\" - every sentence must add value
5. Examples: Include at least 1 concrete example per section
6. Avoid: Short 2-sentence paragraphs, bullet point lists without explanation
7. Code formatting: Any code/config snippets MUST be in fenced code blocks with a language tag (e.g., ```php). Never place code inline in paragraphs.
8. Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
OUTPUT FORMAT (FOLLOW THIS EXACT STRUCTURE):
First, provide a brief conversational response (2-3 sentences) in the required language about:
- What you're going to write about
- Any suggestions or notes about the content
- Reasoning for your approach
Then, insert this EXACT divider on its own line:
~~~ARTICLE~~~
After the divider, write the article in PURE Markdown format in the required language.
MARKDOWN FORMAT REQUIREMENTS:
- Use H2 (##) for section headings
- Use H3 (###) for subsections if needed
- Write clear, concise paragraphs (2-3 sentences each)
- Use bullet points or numbered lists for clarity
- Use **bold** for emphasis, *italic* for subtle emphasis
- Use `inline code` for technical terms
- Use code blocks with language specification for code examples
{$image_instruction}
EXAMPLE OUTPUT:
I'll write a comprehensive guide on this topic, focusing on practical examples and clear explanations. This approach will help readers understand both the concepts and implementation.
~~~ARTICLE~~~
## Heading Here
Content here...
Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversational response from the article content.";
$sections_to_write = array();
foreach ( $plan_json['sections'] as $index => $section ) {
$status = $section['status'] ?? 'pending';
if ( 'done' === $status ) {
continue;
}
$sections_to_write[ $index ] = $section;
}
$section_index = 0;
$total_sections = count( $sections_to_write );
// Send initial writing status
$this->send_status( 'writing', 'Writing content...' );
foreach ( $sections_to_write as $section_position => $section ) {
$section_index++;
$is_first_section = $section_index === 1;
$heading = $section['heading'] ?? $section['title'] ?? '';
$section_id = $section['id'] ?? wp_generate_uuid4();
$plan_json['sections'][ $section_position ]['id'] = $section_id;
$plan_json['sections'][ $section_position ]['status'] = 'in_progress';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_start',
'sectionId' => $section_id,
'heading' => $heading,
'index' => $section_index,
'total' => $total_sections,
)
) . "\n\n";
flush();
// Send section-specific status
$this->send_status( 'writing_section', "Writing section {$section_index} of {$total_sections}: {$heading}" );
$section_prompt = "Write content for the \"{$heading}\" section.\n\n";
$section_prompt .= "Content requirements:\n";
if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) {
foreach ( $section['content'] as $item ) {
if ( ! empty( $item['content'] ) ) {
$section_prompt .= "- {$item['content']}\n";
}
}
}
$section_prompt .= "\nIMPORTANT: Start with a brief conversational note, then include ~~~ARTICLE~~~ divider, then write the section content in Markdown.\n";
if ( $is_first_section ) {
$section_prompt .= "\nNOTE: This is the first section. Start directly with the section heading as an H2 (##), not an H1. The article title is already set separately.\n";
}
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $section_prompt,
),
);
// Log before calling streaming API
error_log( 'WP Agentic Writer: Starting section generation: ' . $heading );
// Send heading block first (but NOT for first section to avoid duplication with post title)
if ( ! $is_first_section && $heading ) {
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'sectionId' => $section_id,
'block' => array(
'type' => 'heading',
'content' => $heading,
'level' => 2,
),
)
) . "\n\n";
flush();
}
// Use streaming for real-time content generation!
$accumulated_content = '';
$section_cost = 0;
$conversational_sent = false;
$divider_found = false;
$markdown_content = ''; // Store complete markdown for later parsing
error_log( 'WP Agentic Writer: Calling OpenRouter streaming API' );
$response = $provider->chat_stream(
$messages,
array( 'temperature' => 0.8 ),
'execution',
function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content, &$section_cost, &$total_cost, $post_id, $provider, &$conversational_sent, &$divider_found, &$markdown_content ) {
// Accumulate the full content
$accumulated_content = $full_content;
// Check for divider
if ( ! $divider_found && strpos( $accumulated_content, '~~~ARTICLE~~~' ) !== false ) {
$divider_found = true;
// Split content on divider
$parts = explode( '~~~ARTICLE~~~', $accumulated_content, 2 );
$conversational = trim( $parts[0] );
$markdown_content = isset( $parts[1] ) ? trim( $parts[1] ) : '';
// CRITICAL: Remove any remaining divider markers from conversational content
$conversational = str_replace( '~~~ARTICLE~~~', '', $conversational );
$conversational = preg_replace( '/~~~ARTICLE~~~[\r\n]*/', '', $conversational );
$conversational = trim( $conversational );
// Send conversational part as chat message
if ( ! empty( $conversational ) && ! $conversational_sent ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational',
'content' => $conversational,
)
) . "\n\n";
flush();
$conversational_sent = true;
}
// Stream raw markdown for display (no parsing yet)
if ( ! empty( $markdown_content ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'markdown_stream',
'content' => $markdown_content,
)
) . "\n\n";
flush();
}
} elseif ( ! $divider_found ) {
// No divider yet, this is all conversational
// Send conversational updates as they stream
if ( ! $conversational_sent ) {
echo "data: " . wp_json_encode(
array(
'type' => 'conversational_stream',
'content' => $accumulated_content,
)
) . "\n\n";
flush();
}
} else {
// Divider found, stream markdown content as it comes
$parts = explode( '~~~ARTICLE~~~', $accumulated_content, 2 );
$markdown_content = isset( $parts[1] ) ? trim( $parts[1] ) : '';
// Stream raw markdown for display (no parsing yet)
if ( ! empty( $markdown_content ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'markdown_stream',
'content' => $markdown_content,
)
) . "\n\n";
flush();
}
}
}
);
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
$section_cost = $response['cost'] ?? 0;
$total_cost += $section_cost;
// Debug: Log execution cost tracking
error_log( 'WP Agentic Writer: Tracking execution cost - post_id=' . $post_id . ', model=' . ($response['model'] ?? 'unknown') . ', cost=' . $section_cost . ', input_tokens=' . ($response['input_tokens'] ?? 0) . ', output_tokens=' . ($response['output_tokens'] ?? 0) );
// Track execution cost for this section.
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'execution',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$section_cost
);
// NOW parse the complete markdown content and send blocks
if ( ! empty( $markdown_content ) ) {
$markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $markdown_content );
foreach ( $markdown_blocks as $block ) {
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block,
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
}
$plan_json['sections'][ $section_position ]['status'] = 'done';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan_json );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_complete',
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
}
// Send complete status
$this->send_status( 'complete', 'Article finished!' );
// Send conversational completion message before complete signal
echo "data: " . wp_json_encode(
array(
'type' => 'conversational',
'content' => "✅ Article generation complete! The content has been added to your editor. Feel free to ask for refinements or adjustments to any section.",
)
) . "\n\n";
flush();
// Send completion message.
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
)
) . "\n\n";
flush();
} catch ( Exception $e ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $e->getMessage(),
)
) . "\n\n";
flush();
}
exit;
}
/**
* Handle execute article request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_execute_article( $request ) {
$params = $request->get_json_params();
$post_id = $params['postId'] ?? 0;
$stream = $params['stream'] ?? false;
$recommended_title = '';
$chat_history = $params['chatHistory'] ?? array();
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$detected_language = $params['detectedLanguage'] ?? $stored_language;
$effective_language = $this->resolve_language_preference( $post_config, $detected_language );
// Get plan from post meta.
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( empty( $plan ) ) {
return new WP_Error(
'no_plan',
__( 'No plan found. Please generate a plan first.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
if ( $stream ) {
$this->stream_execute_article( $plan, $post_id, $post_config, $effective_language );
exit;
}
$plan = $this->ensure_plan_sections_with_tasks( $plan );
// Update post title from the plan title when available.
if ( ! empty( $plan['title'] ) ) {
$recommended_title = sanitize_text_field( $plan['title'] );
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) ) {
if ( empty( $post->post_title ) ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $recommended_title,
)
);
}
}
}
}
// Get OpenRouter provider.
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$image_instruction = "IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Good places for images: after introductions, before complex explanations, to show examples
- Maximum 1-2 image suggestions per section
- Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]";
if ( empty( $post_config['include_images'] ) ) {
$image_instruction = "IMAGE SUGGESTIONS:
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
}
$language_instruction = $this->build_language_instruction( $effective_language, 'article content' );
// Build chat history context for continuity
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- CONVERSATION CONTEXT ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) && 'system' !== strtolower( $msg['role'] ?? '' ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION CONTEXT ---\n";
$chat_history_context .= "Use the above conversation to understand the user's intent and preferences for this article.";
}
// Build SEO instructions if SEO is enabled
$seo_instruction = '';
$internal_links_instruction = '';
if ( ! empty( $post_config['seo_enabled'] ) && ! empty( $post_config['seo_focus_keyword'] ) ) {
$focus_keyword = $post_config['seo_focus_keyword'];
$seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW):
- Focus Keyword: \"{$focus_keyword}\"
- MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning)
- MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words)
- Use the focus keyword 5-8 times naturally throughout the article
- Include the focus keyword in:
* At least 2 H2 or H3 subheadings
* The conclusion paragraph
- Include 2-3 authoritative outbound links to reputable sources (Wikipedia, official documentation, industry leaders)
- When suggesting images, include the focus keyword or related terms in the alt text
- Keep the article title under 60 characters";
// Get internal link suggestions
$internal_links = $this->suggest_internal_links( $post_id, $focus_keyword, 3 );
if ( ! empty( $internal_links ) ) {
$internal_links_instruction = "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n";
foreach ( $internal_links as $link ) {
$internal_links_instruction .= "- [{$link['title']}]({$link['url']})\n";
}
$internal_links_instruction .= "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'.";
}
}
// Build system prompt for article generation.
$system_prompt = "You are an expert technical writer and blogger. Your task is to write engaging, clear, and well-structured content based on the provided article plan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
{$chat_history_context}
Follow these guidelines:
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
- Use clear examples and analogies
- For code blocks, use proper syntax highlighting
- Keep paragraphs concise (2-3 sentences)
- Use transitions between sections
- Write for the specified difficulty level
- Code formatting: Any code/config snippets MUST be in fenced code blocks with a language tag (e.g., ```php). Never place code inline in paragraphs.
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
{$seo_instruction}
{$internal_links_instruction}
IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Good places for images: after introductions, before complex explanations, to show examples
- Maximum 1-2 image suggestions per section
{$image_instruction}";
// Generate content for each section.
$blocks = array();
$total_cost = 0;
$sections_to_write = array();
foreach ( $plan['sections'] as $index => $section ) {
$status = $section['status'] ?? 'pending';
if ( 'done' === $status ) {
continue;
}
$sections_to_write[ $index ] = $section;
}
foreach ( $sections_to_write as $section ) {
$heading = $section['heading'] ?? $section['title'] ?? '';
$section_prompt = $heading ? "Write the \"{$heading}\" section.\n\n" : "Write the next section.\n\n";
$section_prompt .= "Content requirements:\n";
if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) {
foreach ( $section['content'] as $item ) {
if ( ! empty( $item['content'] ) ) {
$section_prompt .= "- {$item['content']}\n";
}
}
}
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $section_prompt,
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.8 ), 'execution' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'execution_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Add section blocks.
if ( $heading ) {
$blocks[] = array(
'type' => 'heading',
'content' => $heading,
'level' => 2,
);
}
$section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response['content'] );
if ( ! empty( $section_blocks ) ) {
$first_block = $section_blocks[0];
if ( isset( $first_block['blockName'] ) && 'core/heading' === $first_block['blockName'] ) {
$first_heading = $first_block['attrs']['content'] ?? '';
if ( $heading && $first_heading && 0 === strcasecmp( trim( $first_heading ), trim( $heading ) ) ) {
array_shift( $section_blocks );
}
}
foreach ( $section_blocks as $block ) {
$blocks[] = $block;
}
} else {
$blocks[] = array(
'type' => 'paragraph',
'content' => $response['content'],
);
}
$total_cost += $response['cost'];
}
if ( ! empty( $sections_to_write ) ) {
foreach ( array_keys( $sections_to_write ) as $section_index ) {
$plan['sections'][ $section_index ]['status'] = 'done';
}
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan );
}
}
// Track total cost.
do_action(
'wp_aw_after_api_request',
$post_id,
$provider->get_execution_model(),
'execution',
0,
0,
$total_cost
);
return new WP_REST_Response(
array(
'blocks' => $blocks,
'cost' => $total_cost,
'recommended_title' => $recommended_title,
),
200
);
}
/**
* Stream article execution from a stored plan.
*
* @since 0.1.0
* @param array $plan Plan data.
* @param int $post_id Post ID.
* @return void
*/
private function stream_execute_article( $plan, $post_id, $post_config = array(), $effective_language = 'english' ) {
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' );
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$total_cost = 0;
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$language_instruction = $this->build_language_instruction( $effective_language, 'article content' );
$image_instruction = "IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Good places for images: after introductions, before complex explanations, to show examples
- Maximum 1-2 image suggestions per section
- Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]";
if ( empty( $post_config['include_images'] ) ) {
$image_instruction = "IMAGE SUGGESTIONS:
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
}
$plan = $this->ensure_plan_sections_with_tasks( $plan );
$sections = isset( $plan['sections'] ) && is_array( $plan['sections'] ) ? $plan['sections'] : array();
$sections_to_write = array();
foreach ( $sections as $index => $section ) {
$status = $section['status'] ?? 'pending';
if ( 'done' === $status ) {
continue;
}
$sections_to_write[ $index ] = $section;
}
$total_sections = count( $sections_to_write );
if ( 0 === $total_sections ) {
$this->send_status( 'complete', 'All outline items are already written.' );
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
)
) . "\n\n";
flush();
return;
}
$this->send_status( 'writing', 'Writing from outline...' );
if ( ! empty( $plan['title'] ) ) {
$plan_title = sanitize_text_field( $plan['title'] );
if ( $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) && empty( $post->post_title ) ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $plan_title,
)
);
}
}
echo "data: " . wp_json_encode(
array(
'type' => 'title_update',
'title' => $plan_title,
)
) . "\n\n";
flush();
}
// Build SEO instructions if SEO is enabled
$seo_instruction = '';
$internal_links_instruction = '';
if ( ! empty( $post_config['seo_enabled'] ) && ! empty( $post_config['seo_focus_keyword'] ) ) {
$focus_keyword = $post_config['seo_focus_keyword'];
$seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW):
- Focus Keyword: \"{$focus_keyword}\"
- MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning)
- MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words)
- Use the focus keyword 5-8 times naturally throughout the article
- Include the focus keyword in:
* At least 2 H2 or H3 subheadings
* The conclusion paragraph
- Include 2-3 authoritative outbound links to reputable sources (Wikipedia, official documentation, industry leaders)
- When suggesting images, include the focus keyword or related terms in the alt text
- Keep the article title under 60 characters";
// Get internal link suggestions
$internal_links = $this->suggest_internal_links( $post_id, $focus_keyword, 3 );
if ( ! empty( $internal_links ) ) {
$internal_links_instruction = "\n\nINTERNAL LINKS (optional - use where contextually relevant):\n";
foreach ( $internal_links as $link ) {
$internal_links_instruction .= "- [{$link['title']}]({$link['url']})\n";
}
$internal_links_instruction .= "Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'.";
}
}
$system_prompt = "You are an expert technical writer and blogger. Your task is to write engaging, clear, and well-structured content based on the provided article plan.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
Follow these guidelines:
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
- Use clear examples and analogies
- For code blocks, use proper syntax highlighting
- Keep paragraphs concise (2-3 sentences)
- Use transitions between sections
- Write for the specified difficulty level
- Code formatting: Any code/config snippets MUST be in fenced code blocks with a language tag (e.g., ```php). Never place code inline in paragraphs.
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
{$seo_instruction}
{$internal_links_instruction}
IMAGE SUGGESTIONS:
- Suggest where images would enhance understanding
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
- Good places for images: after introductions, before complex explanations, to show examples
- Maximum 1-2 image suggestions per section
{$image_instruction}";
$section_index = 0;
foreach ( $sections_to_write as $section_position => $section ) {
$section_index++;
$heading = $section['heading'] ?? $section['title'] ?? '';
$status_message = $heading
? sprintf( 'Writing section %d of %d: %s', $section_index, $total_sections, $heading )
: sprintf( 'Writing section %d of %d', $section_index, $total_sections );
$section_id = $section['id'] ?? wp_generate_uuid4();
$plan['sections'][ $section_position ]['id'] = $section_id;
$plan['sections'][ $section_position ]['status'] = 'in_progress';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_start',
'sectionId' => $section_id,
'heading' => $heading,
'index' => $section_index,
'total' => $total_sections,
)
) . "\n\n";
flush();
$this->send_status( 'writing_section', $status_message );
$section_prompt = $heading ? "Write the \"{$heading}\" section.\n\n" : "Write the next section.\n\n";
$section_prompt .= "Content requirements:\n";
if ( ! empty( $section['content'] ) && is_array( $section['content'] ) ) {
foreach ( $section['content'] as $item ) {
if ( ! empty( $item['content'] ) ) {
$section_prompt .= "- {$item['content']}\n";
}
}
}
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => $section_prompt,
),
);
$accumulated_content = '';
$response = $provider->chat_stream(
$messages,
array( 'temperature' => 0.8 ),
'execution',
function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content ) {
$accumulated_content = $full_content;
}
);
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
$section_cost = $response['cost'] ?? 0;
$total_cost += $section_cost;
// Track cost for this section
if ( $section_cost > 0 ) {
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? 'unknown',
'execution',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$section_cost
);
}
if ( ! empty( $accumulated_content ) ) {
$section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $accumulated_content );
foreach ( $section_blocks as $block ) {
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block,
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
}
$plan['sections'][ $section_position ]['status'] = 'done';
if ( $post_id > 0 ) {
update_post_meta( $post_id, '_wpaw_plan', $plan );
}
echo "data: " . wp_json_encode(
array(
'type' => 'section_complete',
'sectionId' => $section_id,
)
) . "\n\n";
flush();
}
$this->send_status( 'complete', 'Article finished!' );
// Suggest meta description generation if SEO is enabled
if ( ! empty( $post_config['seo_enabled'] ) && $post_id > 0 ) {
echo "data: " . wp_json_encode(
array(
'type' => 'assistant_message',
'message' => '✅ Article complete! You can now generate the meta description in config panel.',
)
) . "\n\n";
flush();
}
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'totalCost' => $total_cost,
)
) . "\n\n";
flush();
}
/**
* Handle reformat blocks request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_reformat_blocks( $request ) {
$params = $request->get_json_params();
$blocks = $params['blocks'] ?? array();
$post_id = $params['postId'] ?? 0;
$recommended_title = '';
$title_updated = false;
if ( empty( $blocks ) || ! is_array( $blocks ) ) {
return new WP_Error(
'no_blocks',
__( 'Blocks are required to reformat.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$results = array();
if ( $post_id > 0 ) {
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( is_array( $plan ) && ! empty( $plan['title'] ) ) {
$recommended_title = sanitize_text_field( $plan['title'] );
}
}
foreach ( $blocks as $block ) {
$client_id = $block['clientId'] ?? $block['attrs']['clientId'] ?? '';
$block_type = $block['name'] ?? $block['blockName'] ?? 'core/paragraph';
$block_attrs = $block['attributes'] ?? $block['attrs'] ?? array();
if ( empty( $client_id ) ) {
continue;
}
if ( 'core/paragraph' !== $block_type ) {
continue;
}
$content = $this->extract_block_content_from_attrs( $block_type, $block_attrs );
if ( '' === trim( (string) $content ) ) {
continue;
}
$parsed_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $content );
if ( empty( $parsed_blocks ) ) {
continue;
}
$results[] = array(
'clientId' => $client_id,
'blocks' => $parsed_blocks,
);
}
if ( $post_id > 0 && '' !== $recommended_title ) {
$post = get_post( $post_id );
if ( $post && current_user_can( 'edit_post', $post_id ) ) {
if ( empty( $post->post_title ) ) {
wp_update_post(
array(
'ID' => $post_id,
'post_title' => $recommended_title,
)
);
$title_updated = true;
}
}
}
return new WP_REST_Response(
array(
'results' => $results,
'recommended_title' => $recommended_title,
'title_updated' => $title_updated,
),
200
);
}
/**
* Handle regenerate block request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_regenerate_block( $request ) {
$params = $request->get_json_params();
$block_content = $params['blockContent'] ?? '';
$context = $params['context'] ?? '';
$post_id = $params['postId'] ?? 0;
if ( empty( $block_content ) ) {
return new WP_Error(
'no_content',
__( 'Block content is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Get OpenRouter provider.
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$messages = array(
array(
'role' => 'system',
'content' => 'You are an expert technical writer. Rewrite the provided content to improve it while maintaining the same meaning and key information.',
),
array(
'role' => 'user',
'content' => "Context: {$context}\n\nOriginal content:\n\n{$block_content}\n\nPlease rewrite this content.",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.8 ), 'execution' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'regeneration_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Track cost (always track for debugging).
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'regeneration',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
return new WP_REST_Response(
array(
'content' => $response['content'],
'cost' => $response['cost'] ?? 0,
),
200
);
}
/**
* Handle get cost tracking request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response Response.
*/
public function handle_get_cost_tracking( $request ) {
$post_id = $request->get_param( 'post_id' );
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
$data = $cost_tracker->get_frontend_data( $post_id );
return new WP_REST_Response( $data, 200 );
}
/**
* Extract JSON from string.
*
* @since 0.1.0
* @param string $string String containing JSON.
* @return array|null Decoded JSON or null if invalid.
*/
private function extract_json( $string ) {
// Try to find JSON in the string.
if ( preg_match( '/\{.*\}/s', $string, $matches ) ) {
$json = json_decode( $matches[0], true );
if ( json_last_error() === JSON_ERROR_NONE ) {
return $json;
}
}
return null;
}
/**
* Handle get models request.
*
* @since 0.1.0
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_models() {
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->get_cached_models();
if ( is_wp_error( $models ) ) {
return $models;
}
return new WP_REST_Response( $models, 200 );
}
/**
* Handle refresh models request.
*
* @since 0.1.0
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_refresh_models() {
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->fetch_and_cache_models( true );
if ( is_wp_error( $models ) ) {
return $models;
}
return new WP_REST_Response(
array(
'models' => $models,
'message' => __( 'Models refreshed successfully.', 'wp-agentic-writer' ),
),
200
);
}
/**
* Handle check clarity request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_check_clarity( $request ) {
$params = $request->get_json_params();
$topic = $params['topic'] ?? '';
$answers = $params['answers'] ?? array();
$post_id = $params['postId'] ?? 0;
$mode = $params['mode'] ?? 'generation';
$chat_history = $params['chatHistory'] ?? array();
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
$post_config_context = $this->build_post_config_context( $post_config );
$preferred_language = $this->resolve_language_preference( $post_config, '' );
$language_hint = '';
if ( 'auto' !== ( $post_config['language'] ?? 'auto' ) ) {
$language_hint = "\n\nPreferred language: {$preferred_language}. Ask questions in that language.";
}
if ( empty( $topic ) ) {
return new WP_Error(
'no_topic',
__( 'Topic is required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
// Get settings.
$settings = get_option( 'wp_agentic_writer_settings', array() );
$enabled = $settings['enable_clarification_quiz'] ?? true;
$threshold = $settings['clarity_confidence_threshold'] ?? '0.6';
$required_categories = $settings['required_context_categories'] ?? array(
'target_outcome',
'target_audience',
'tone',
'content_depth',
'expertise_level',
'content_type',
'pov',
);
// If quiz is disabled, always return clear.
if ( ! $enabled ) {
return new WP_REST_Response(
array(
'result' => array(
'is_clear' => true,
'confidence' => 1.0,
'questions' => array(),
),
'cost' => 0,
),
200
);
}
// Build context from answers if available.
$context = '';
if ( ! empty( $answers ) ) {
$context = "\n\nPrevious answers:\n";
foreach ( $answers as $answer ) {
$context .= "- {$answer['question']}: {$answer['answer']}\n";
}
}
// Build chat history context for continuity.
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- CONVERSATION HISTORY (IMPORTANT - use this context!) ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION HISTORY ---\n";
$chat_history_context .= "\nIMPORTANT: The user's current request \"" . $topic . "\" is a CONTINUATION of the above conversation. Extract topic/context from the chat history. If the conversation already discussed a specific topic, the user likely wants to create an outline for THAT topic. Do NOT ask \"what topic?\" if it's already clear from the conversation.";
}
$memory_context = $this->get_post_memory_context( $post_id );
$followup_hint = '';
if ( 'refinement' === $mode && ! empty( $memory_context ) ) {
$followup_hint = "\n\nThis is a follow-up request to an existing article. Use the post memory below to avoid asking generic questions already covered unless the request is ambiguous within that context.";
}
// Also treat chat history as follow-up context.
if ( ! empty( $chat_history_context ) ) {
$followup_hint .= "\n\nThis request continues from a previous chat conversation. Use the conversation history to understand what the user wants.";
}
$system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively.
IMPORTANT RULES:
1. DETECT LANGUAGE: Identify the user's language (Indonesian, English, etc.) and write ALL questions in that SAME language
2. PRIORITIZE TOPIC CONTEXT: Ask about the topic/scope/platform FIRST before writing-style questions
3. USE OPEN-TEXT for complex questions that need detailed explanations
4. USE MULTIPLE CHOICE only for simple binary/selection questions
EVALUATION CATEGORIES (in priority order):
**TOPIC-SPECIFIC CONTEXT (Most Important):**
1. topic_scope - What specific aspects should be covered? (e.g., for \"page builder\": platforms covered? WordPress only? Comparison? Features? Use cases?)
2. target_platform - Which platform/tool? (WordPress/Shopify/Webflow/Generic/Multiple/etc)
3. specific_focus - What angle? (Technical tutorial/Business benefits/Comparison/Getting started/Best practices)
4. missing_info - What key details are unclear?
**WRITING CONTEXT (Secondary):**
5. target_outcome - What should this achieve? (Education/Marketing/Sales/Comparison/Tutorial/Opinion)
6. target_audience - Who reads this? (Beginners/Developers/Business owners/Marketers/General audience)
7. content_depth - How detailed? (Quick overview/Standard guide/Comprehensive/Technical deep-dive)
QUESTION TYPES:
1. **single_choice** - For simple selections (one answer):
Use for: platform, outcome, audience type, depth level
Example:
{
'id': 'q1',
'category': 'target_platform',
'question': 'Platform apa yang ingin dibahas? (What platform to focus on?)',
'type': 'single_choice',
'options': [
{ 'value': 'WordPress only', 'default': true },
{ 'value': 'Comparison: WordPress vs others', 'default': false },
{ 'value': 'General page builders (multiple platforms)', 'default': false },
{ 'value': 'Specific platform (mention below)', 'default': false }
]
}
2. **multiple_choice** - For selecting multiple items:
Use for: topics to cover, platforms to compare
Example:
{
'id': 'q2',
'category': 'topic_scope',
'question': 'Apa yang harus dibahas? (What to cover?)',
'type': 'multiple_choice',
'options': [
{ 'value': 'Benefits/advantages', 'default': true },
{ 'value': 'How to choose', 'default': true },
{ 'value': 'Popular plugins/platforms', 'default': false },
{ 'value': 'Use cases/examples', 'default': false },
{ 'value': 'Technical details', 'default': false }
]
}
3. **open_text** - For detailed explanations (RECOMMENDED for complex topics):
Use for: scope clarification, specific requirements, custom details
Example:
{
'id': 'q3',
'category': 'topic_scope',
'question': 'Jelaskan lebih detail apa yang ingin Anda bahas tentang page builder. Apakah ada platform spesifik? Fokus ke mana? (Explain in detail what you want to cover about page builders. Any specific platform? What focus?)',
'type': 'open_text',
'placeholder': 'Contoh: Fokus ke WordPress plugin seperti Elementor, Divi, Brizy. Jelaskan kelebihan masing-masing...',
'max_length': 500
}
CONFIDENCE CALCULATION:
- Start at 100% (1.0)
- Subtract 20% for each missing HIGH-PRIORITY category (topic_scope, target_platform, specific_focus)
- Subtract 10% for each missing SECONDARY category (target_outcome, target_audience, content_depth)
- If confidence < {$threshold}, generate questions starting with HIGH-PRIORITY
QUESTION GENERATION STRATEGY:
1. Always detect user language first and match it
2. If topic is vague (e.g., \"page builder\" without platform), ask open_text about scope
3. If multiple possible platforms, ask single_choice to narrow down
4. If multiple topics could apply, ask multiple_choice
5. Only ask writing-style questions if topic context is already clear
Return ONLY valid JSON:
{
'is_clear': true/false,
'confidence': 0.0-1.0,
'detected_language': 'indonesian'|'english'|'other',
'missing_categories': ['topic_scope', 'target_platform'],
'questions': [ ... ]
}
No markdown, no explanation - just JSON.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nRequired Categories: " . implode( ', ', $required_categories ) . "\n\nEvaluate this request and determine which context is missing.{$chat_history_context}{$context}{$post_config_context}{$memory_context}{$followup_hint}{$language_hint}",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
if ( is_wp_error( $response ) ) {
// Log error and use default questions instead of failing.
error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() );
$result = $this->get_default_clarification_questions( $topic );
return new WP_REST_Response(
array(
'result' => $result,
'cost' => 0,
),
200
);
}
// Extract JSON from response.
$content = $response['content'];
$result = $this->extract_json( $content );
if ( null === $result ) {
// Log parse error and use default questions instead of failing.
error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' );
$result = $this->get_default_clarification_questions( $topic );
return new WP_REST_Response(
array(
'result' => $result,
'cost' => 0,
),
200
);
}
// Track cost (always track for debugging).
$post_id = $params['postId'] ?? 0;
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'clarity_check',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
// Always add configuration questions, even if no clarity questions
if ( ! isset( $result['questions'] ) || ! is_array( $result['questions'] ) ) {
$result['questions'] = array();
}
$result['questions'] = $this->append_config_questions( $result['questions'], $post_config );
// If only config questions exist and clarity is clear, still show config
if ( empty( $result['questions'] ) === false && $result['is_clear'] === true ) {
$result['is_clear'] = false; // Force quiz to show for config questions
}
return new WP_REST_Response(
array(
'result' => $result,
'cost' => $response['cost'] ?? 0,
),
200
);
}
/**
* Append configuration questions to clarity quiz.
*
* @since 0.1.0
* @param array $questions Existing questions.
* @param array $post_config Post configuration.
* @return array Updated questions with config prompts.
*/
private function append_config_questions( $questions, $post_config ) {
$detected_language = $post_config['language'] ?? 'auto';
$is_indonesian = ( 'Indonesian' === $detected_language );
// Get preferred languages from settings
$settings = get_option( 'wp_agentic_writer_settings', array() );
$preferred_languages = array_merge(
$settings['preferred_languages'] ?? array( 'auto', 'English', 'Indonesian' ),
$settings['custom_languages'] ?? array()
);
// Build language options from site preferences
$language_options = array();
foreach ( $preferred_languages as $lang ) {
$language_options[] = array(
'value' => $lang,
'default' => ( 'auto' === $lang ),
);
}
// Language selection question (FIRST)
$questions[] = array(
'id' => 'config_language',
'category' => 'config',
'question' => $is_indonesian
? '🌍 Pilih Bahasa Artikel (Select Article Language)'
: '🌍 Select Article Language',
'type' => 'single_choice',
'options' => $language_options,
);
// Single consolidated config question with all fields
$questions[] = array(
'id' => 'config_all',
'category' => 'config',
'question' => $is_indonesian
? '⚙️ Konfigurasi Artikel (Article Configuration)'
: '⚙️ Article Configuration',
'type' => 'config_form',
'fields' => array(
array(
'id' => 'web_search',
'label' => $is_indonesian
? '🔍 Pencarian Web (Web Search)'
: '🔍 Web Search',
'description' => $is_indonesian
? 'Aktifkan untuk data terkini (~$0.02/pencarian)'
: 'Enable for current data (~$0.02/search)',
'type' => 'toggle',
'default' => false,
),
array(
'id' => 'seo',
'label' => $is_indonesian
? '📊 Optimasi SEO (SEO Optimization)'
: '📊 SEO Optimization',
'description' => $is_indonesian
? 'Optimalkan artikel untuk mesin pencari'
: 'Optimize article for search engines',
'type' => 'toggle',
'default' => true,
),
array(
'id' => 'focus_keyword',
'label' => $is_indonesian
? '🎯 Kata Kunci Fokus (Focus Keyword)'
: '🎯 Focus Keyword',
'placeholder' => $is_indonesian ? 'Contoh: wordpress plugin' : 'Example: wordpress plugin',
'type' => 'text',
'max_length' => 100,
'conditional' => 'seo',
'default' => $post_config['seo_focus_keyword'] ?? '',
'description' => ! empty( $post_config['seo_focus_keyword'] )
? ( $is_indonesian ? '💡 Disarankan AI - edit jika perlu' : '💡 AI-suggested - edit if needed' )
: '',
),
array(
'id' => 'secondary_keywords',
'label' => $is_indonesian
? '🔑 Kata Kunci Sekunder (Secondary Keywords)'
: '🔑 Secondary Keywords',
'placeholder' => $is_indonesian ? 'Pisahkan dengan koma' : 'Comma-separated',
'type' => 'text',
'max_length' => 200,
'conditional' => 'seo',
'default' => $post_config['seo_secondary_keywords'] ?? '',
'description' => ! empty( $post_config['seo_secondary_keywords'] )
? ( $is_indonesian ? '💡 Disarankan AI - edit jika perlu' : '💡 AI-suggested - edit if needed' )
: '',
),
),
);
return $questions;
}
/**
* Handle block refine request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_block_refine( $request ) {
$params = $request->get_json_params();
$block_id = $params['blockId'] ?? '';
$block_type = $params['blockType'] ?? '';
$block_content = $params['blockContent'] ?? '';
$refinement_request = $params['refinementRequest'] ?? '';
$article_context = $params['articleContext'] ?? array();
$post_id = $params['postId'] ?? 0;
$stream = $params['stream'] ?? false;
$chat_history = $params['chatHistory'] ?? array();
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
if ( empty( $block_content ) || empty( $refinement_request ) ) {
return new WP_Error(
'missing_data',
__( 'Block content and refinement request are required.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// If streaming is requested, use streaming response.
if ( $stream ) {
return $this->stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config );
}
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
// Build context from article structure.
$context_str = "\n\nArticle Context:\n";
$context_str .= "Title: " . ( $article_context['title'] ?? 'Unknown' ) . "\n";
if ( ! empty( $article_context['previousBlock'] ) ) {
$context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n";
}
$context_str .= "Current block type: " . $block_type . "\n";
$context_str .= "Current content:\n" . $block_content . "\n";
if ( ! empty( $article_context['nextBlock'] ) ) {
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
// Add chat history context if available
$chat_history_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_history_context = "\n\n--- ORIGINAL CONVERSATION ---\n";
foreach ( $chat_history as $msg ) {
$role = isset( $msg['role'] ) ? ucfirst( $msg['role'] ) : 'Unknown';
$content = isset( $msg['content'] ) ? $msg['content'] : '';
if ( ! empty( $content ) && 'system' !== strtolower( $msg['role'] ?? '' ) ) {
$chat_history_context .= "{$role}: {$content}\n\n";
}
}
$chat_history_context .= "--- END CONVERSATION ---\n";
$chat_history_context .= "This shows the original discussion that led to this article.";
}
// Add plan context if available
$plan_context = '';
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
if ( ! empty( $plan ) && is_array( $plan ) ) {
$plan_context = "\n\nOriginal Article Outline:\n";
if ( ! empty( $plan['title'] ) ) {
$plan_context .= "Title: {$plan['title']}\n";
}
if ( ! empty( $plan['sections'] ) && is_array( $plan['sections'] ) ) {
foreach ( $plan['sections'] as $section ) {
$heading = $section['heading'] ?? $section['title'] ?? '';
if ( ! empty( $heading ) ) {
$plan_context .= "- {$heading}\n";
}
}
}
}
$system_prompt = "You are an expert editor helping refine a specific section of an article.
{$context_str}
{$plan_context}
{$chat_history_context}
USER REQUEST: {$refinement_request}
TASK:
Refine the current section content considering:
1. How it fits into the overall article flow
2. Consistency with surrounding sections
3. The original intent from the conversation and outline
4. The user's specific refinement request
5. Maintaining the original key information
Provide the refined content in Markdown format.
Keep the same block type (paragraph, heading, list, etc.).";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Please refine this content.",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'execution' );
if ( is_wp_error( $response ) ) {
return new WP_Error(
'refinement_error',
$response->get_error_message(),
array( 'status' => 500 )
);
}
// Parse refined content as Gutenberg blocks.
$blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response['content'] );
// Track cost (always track for debugging).
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'block_refinement',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
return new WP_REST_Response(
array(
'blocks' => $blocks,
'blockId' => $block_id,
'cost' => $response['cost'] ?? 0,
),
200
);
}
/**
* Stream block refinement response.
*
* @since 0.1.0
* @param string $block_id Block ID.
* @param string $block_type Block type.
* @param string $block_content Block content.
* @param string $refinement_request Refinement request.
* @param array $article_context Article context.
* @param int $post_id Post ID.
* @return void Streams response to client.
*/
private function stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config = array() ) {
// Set headers for streaming.
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering.
// Flush output buffer to ensure immediate streaming.
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
$language_instruction = $this->build_language_instruction( $effective_language, 'refined content' );
try {
// Build context from article structure.
$context_str = "\n\nArticle Context:\n";
$context_str .= "Title: " . ( $article_context['title'] ?? 'Unknown' ) . "\n";
if ( ! empty( $article_context['previousBlock'] ) ) {
$context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n";
}
$context_str .= "Current block type: " . $block_type . "\n";
$context_str .= "Current content:\n" . $block_content . "\n";
if ( ! empty( $article_context['nextBlock'] ) ) {
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
$system_prompt = "You are an expert content refiner. Your task is to rewrite and improve content based on the user's request.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
{$context_str}
USER REQUEST: {$refinement_request}
IMPORTANT RULES:
1. Rewrite the content to fulfill the refinement request
2. Maintain the core meaning and key information
3. Ensure it flows well with surrounding sections
4. Match the article's overall tone and style
5. Return ONLY the refined content, no explanations or conversational text
Output format:
- If paragraph: Return the refined text only
- If heading: Return the refined heading text only
- If list: Return the list items, one per line
- No markdown formatting like ```text`` wrappers
- No conversational filler
- Start directly with the refined content";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Refine this content.",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'execution' );
if ( is_wp_error( $response ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $response->get_error_message(),
)
) . "\n\n";
flush();
exit;
}
// Track cost (always track for debugging).
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'block_refinement',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
$payload = $this->parse_refined_payload( $response['content'] );
$refined_content = $this->clean_refined_content( $payload['content'] );
$resolved_block_type = $payload['blockType'] ?? $block_type;
// Parse as block based on type and create proper Gutenberg block structure
$block_data = array();
$block_name = 'core/paragraph'; // Default
if ( $resolved_block_type === 'core/paragraph' ) {
$block_name = 'core/paragraph';
$block_attrs = array( 'content' => $refined_content );
// Create proper HTML for paragraph
$block_html = '<p>' . $refined_content . '</p>';
// Create proper block structure
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
} elseif ( $resolved_block_type === 'core/heading' ) {
$block_name = 'core/heading';
// Detect heading level from markdown-style if present
$level = 2;
if ( preg_match( '/^(#{1,6})\s/', $refined_content ) ) {
$count = strspn( $refined_content, '#' );
$level = min( $count, 6 );
$refined_content = trim( substr( $refined_content, $count ) );
}
$block_attrs = array(
'level' => $level,
'content' => $refined_content,
);
$tag = 'h' . $level;
$block_html = "<{$tag}>{$refined_content}</{$tag}>";
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
} elseif ( $resolved_block_type === 'core/list' ) {
$block_name = 'core/list';
$lines = explode( "\n", $refined_content );
$lines = array_filter( array_map( 'trim', $lines ) );
// Create inner blocks for list items
$inner_blocks = array();
foreach ( $lines as $line ) {
$inner_blocks[] = array(
'blockName' => 'core/list-item',
'attrs' => array( 'content' => $line ),
'innerHTML' => '<li>' . $line . '</li>',
);
}
$block_attrs = array( 'ordered' => false );
$block_html = '<ul>' . implode( '', array_map( function( $item ) {
return $item['innerHTML'];
}, $inner_blocks ) ) . '</ul>';
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerBlocks' => $inner_blocks,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
} else {
// Fallback to paragraph for unknown types
$block_name = 'core/paragraph';
$block_attrs = array( 'content' => $refined_content );
$block_html = '<p>' . $refined_content . '</p>';
$block_data = array(
'blockName' => $block_name,
'attrs' => $block_attrs,
'innerHTML' => $block_html,
'clientId' => $block_id,
);
}
// Send the refined block
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block_data,
)
) . "\n\n";
flush();
// Small delay for visual effect
usleep( 100000 );
// Send completion message.
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'blockId' => $block_id,
'totalCost' => $response['cost'],
)
) . "\n\n";
flush();
} catch ( Exception $e ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $e->getMessage(),
)
) . "\n\n";
flush();
}
exit;
}
/**
* Check clarity before article generation.
*
* @since 0.1.0
* @param string $topic User topic.
* @param array $answers Previous answers.
* @param WP_Agentic_Writer_OpenRouter_Provider $provider OpenRouter provider.
* @return array Clarity check result with is_clear and questions.
*/
private function check_clarity_before_generation( $topic, $answers, $provider ) {
// Get settings.
$settings = get_option( 'wp_agentic_writer_settings', array() );
$enabled = $settings['enable_clarification_quiz'] ?? true;
$threshold = $settings['clarity_confidence_threshold'] ?? '0.6';
$required_categories = $settings['required_context_categories'] ?? array(
'target_outcome',
'target_audience',
'tone',
'content_depth',
'expertise_level',
'content_type',
'pov',
);
// If quiz is disabled, always return clear.
if ( ! $enabled ) {
return array( 'is_clear' => true, 'confidence' => 1.0, 'questions' => array() );
}
// Build context from answers if available.
$context = '';
if ( ! empty( $answers ) ) {
$context = "\n\nPrevious answers:\n";
foreach ( $answers as $answer ) {
$context .= "- {$answer['question']}: {$answer['answer']}\n";
}
}
$system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively.
Evaluate the user's request and determine which context categories are clear:
CATEGORIES TO EVALUATE:
1. target_outcome - What should this content achieve? (education/marketing/sales/entertainment/brand_awareness)
2. target_audience - Who is reading this? (demographics, role, knowledge level)
3. tone - How should we sound? (formal/casual/technical/friendly/professional/conversational)
4. content_depth - How comprehensive? (quick_overview/standard_guide/detailed_analysis/comprehensive)
5. expertise_level - Reader's knowledge? (beginner/intermediate/advanced/expert)
6. content_type - What format? (tutorial/how_to/opinion/comparison/listicle/case_study/news_analysis)
7. pov - Whose perspective? (first_person/third_person/expert_voice/neutral)
For each MISSING category, generate a clarifying question using PREDEFINED OPTIONS.
Use 'single_choice' or 'multiple_choice' types - NEVER 'open_text'.
QUESTION STRUCTURE:
{
'id': 'q1',
'category': 'target_outcome',
'question': 'What is the primary goal of this content?',
'type': 'single_choice',
'options': [
{ 'value': 'Education - Teach something new', 'default': true },
{ 'value': 'Marketing - Promote a product/service', 'default': false },
{ 'value': 'Sales - Drive conversions/signups', 'default': false },
{ 'value': 'Entertainment - Engage and entertain', 'default': false },
{ 'value': 'Brand Awareness - Build authority/trust', 'default': false }
]
}
CONFIDENCE CALCULATION:
- Start at 100% (1.0)
- Subtract 15% for each missing required category
- If confidence < {$threshold}, generate questions for ALL missing categories
Return ONLY valid JSON with this structure:
{
'is_clear': true/false,
'confidence': 0.0-1.0,
'missing_categories': ['category1', 'category2'],
'questions': [ ... ]
}
No markdown, no explanation - just JSON.";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Topic: {$topic}\n\nRequired Categories: " . implode( ', ', $required_categories ) . "\n\nEvaluate this request and determine which context is missing.{$context}",
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
if ( is_wp_error( $response ) ) {
// Log error and use default questions instead of skipping.
error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() );
return $this->get_default_clarification_questions( $topic );
}
// Extract JSON from response.
$content = $response['content'];
$result = $this->extract_json( $content );
if ( null === $result ) {
// Log parse error and use default questions instead of skipping.
error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' );
return $this->get_default_clarification_questions( $topic );
}
return $result;
}
/**
* Send status update via SSE.
*
* @since 0.1.0
* @param string $status Status code.
* @param string $message Status message.
*/
private function send_status( $status, $message = '' ) {
$status_icons = array(
'starting' => '',
'planning' => '',
'plan_complete' => '',
'writing' => '',
'writing_section' => '',
'complete' => '',
);
$icon = isset( $status_icons[ $status ] ) ? $status_icons[ $status ] : '';
echo "data: " . wp_json_encode(
array(
'type' => 'status',
'status' => $status,
'message' => $message,
'icon' => $icon,
)
) . "\n\n";
flush();
}
/**
* Get default clarification questions when AI fails.
*
* @since 0.1.0
* @param string $topic User's topic.
* @return array Clarification result with default questions.
*/
private function get_default_clarification_questions( $topic ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
$required_categories = $settings['required_context_categories'] ?? array(
'target_outcome',
'target_audience',
'tone',
'content_depth',
'expertise_level',
'content_type',
'pov',
);
$questions = array();
$question_id = 1;
$question_templates = array(
'target_outcome' => array(
'category' => 'target_outcome',
'question' => 'What is the primary goal of this content?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Education - Teach something new', 'default' => true ),
array( 'value' => 'Marketing - Promote a product/service', 'default' => false ),
array( 'value' => 'Sales - Drive conversions', 'default' => false ),
array( 'value' => 'Entertainment - Engage readers', 'default' => false ),
array( 'value' => 'Brand Awareness - Build authority', 'default' => false ),
),
),
'target_audience' => array(
'category' => 'target_audience',
'question' => 'Who is the primary audience for this content?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'General public / Beginners', 'default' => true ),
array( 'value' => 'Professionals in the field', 'default' => false ),
array( 'value' => 'Potential customers', 'default' => false ),
array( 'value' => 'Existing customers/users', 'default' => false ),
array( 'value' => 'Industry peers / Experts', 'default' => false ),
),
),
'tone' => array(
'category' => 'tone',
'question' => 'What tone should this content have?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Professional & Authoritative', 'default' => true ),
array( 'value' => 'Friendly & Conversational', 'default' => false ),
array( 'value' => 'Technical & Detailed', 'default' => false ),
array( 'value' => 'Casual & Entertaining', 'default' => false ),
array( 'value' => 'Formal & Academic', 'default' => false ),
),
),
'content_depth' => array(
'category' => 'content_depth',
'question' => 'How comprehensive should this content be?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Quick overview (500-800 words)', 'default' => false ),
array( 'value' => 'Standard guide (800-1500 words)', 'default' => true ),
array( 'value' => 'Detailed analysis (1500-2500 words)', 'default' => false ),
array( 'value' => 'Comprehensive deep-dive (2500+ words)', 'default' => false ),
),
),
'expertise_level' => array(
'category' => 'expertise_level',
'question' => 'What is the target audience\'s expertise level?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Beginner - No prior knowledge', 'default' => true ),
array( 'value' => 'Intermediate - Basic understanding', 'default' => false ),
array( 'value' => 'Advanced - Deep technical knowledge', 'default' => false ),
array( 'value' => 'Expert - Industry professional', 'default' => false ),
),
),
'content_type' => array(
'category' => 'content_type',
'question' => 'What type of content works best for this topic?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Tutorial / How-to guide', 'default' => true ),
array( 'value' => 'Opinion / Commentary', 'default' => false ),
array( 'value' => 'Comparison / Review', 'default' => false ),
array( 'value' => 'Listicle / Tips', 'default' => false ),
array( 'value' => 'Case study', 'default' => false ),
array( 'value' => 'News analysis', 'default' => false ),
),
),
'pov' => array(
'category' => 'pov',
'question' => 'From what perspective should this be written?',
'type' => 'single_choice',
'options' => array(
array( 'value' => 'Third person (objective, "it", "they")', 'default' => true ),
array( 'value' => 'First person (personal, "I", "my")', 'default' => false ),
array( 'value' => 'Expert voice (authoritative, experienced)', 'default' => false ),
array( 'value' => 'Neutral / Unbiased', 'default' => false ),
),
),
);
foreach ( $required_categories as $category ) {
if ( isset( $question_templates[ $category ] ) ) {
$q = $question_templates[ $category ];
$q['id'] = 'q' . $question_id++;
$questions[] = $q;
}
}
return array(
'is_clear' => false,
'confidence' => 0.0,
'missing_categories' => $required_categories,
'questions' => $questions,
);
}
/**
* Handle chat-based block refinement request.
*
* @since 0.1.0
* @param WP_REST_Request $request Full request data.
* @return void Streams response to client.
*/
public function handle_refine_from_chat( $request ) {
$params = $request->get_json_params();
$message = $params['topic'] ?? '';
$selected_block = $params['selectedBlockClientId'] ?? '';
$post_id = $params['postId'] ?? 0;
$blocks_to_refine = $params['blocksToRefine'] ?? array();
$all_blocks = $params['allBlocks'] ?? array();
$diff_plan = ! empty( $params['diffPlan'] );
$post_config = $this->resolve_post_config_from_request( $params, $post_id );
if ( empty( $blocks_to_refine ) || ! is_array( $blocks_to_refine ) ) {
return new WP_Error(
'no_blocks_mentioned',
__( 'No valid blocks found to refine. Try mentioning blocks like @this, @previous, or specific blocks like @paragraph-1', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Stream refinement for each mentioned block
$this->stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config );
// Return early to avoid REST API trying to send headers after streaming
exit;
}
/**
* Save section-to-block mapping.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_save_section_blocks( $request ) {
$params = $request->get_json_params();
$post_id = intval( $params['postId'] ?? 0 );
$section_id = sanitize_text_field( $params['sectionId'] ?? '' );
$block_ids = $params['blockIds'] ?? array();
if ( $post_id <= 0 || empty( $section_id ) || ! is_array( $block_ids ) ) {
return new WP_Error(
'invalid_section_blocks',
__( 'Invalid section block mapping request.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$block_ids = array_values(
array_filter(
array_map( 'sanitize_text_field', $block_ids )
)
);
$mapping = get_post_meta( $post_id, '_wpaw_section_blocks', true );
if ( ! is_array( $mapping ) ) {
$mapping = array();
}
$mapping[ $section_id ] = $block_ids;
update_post_meta( $post_id, '_wpaw_section_blocks', $mapping );
return new WP_REST_Response(
array(
'success' => true,
'sectionId' => $section_id,
'blockCount' => count( $block_ids ),
),
200
);
}
/**
* Get section-to-block mapping for a post.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_get_section_blocks( $request ) {
$post_id = intval( $request['post_id'] ?? 0 );
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$mapping = get_post_meta( $post_id, '_wpaw_section_blocks', true );
if ( ! is_array( $mapping ) ) {
$mapping = array();
}
return new WP_REST_Response(
array(
'sectionBlocks' => $mapping,
),
200
);
}
/**
* Stream block refinement from chat to client.
*
* @since 0.1.0
* @param array $blocks_to_refine Array of block objects to refine (from editor).
* @param string $message User's refinement message.
* @param string $selected_block Currently selected block client ID.
* @param int $post_id Post ID.
* @return void Streams response to client.
*/
private function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = array() ) {
// Set headers for streaming.
header( 'Content-Type: text/event-stream' );
header( 'Cache-Control: no-cache' );
header( 'X-Accel-Buffering: no' ); // Disable Nginx buffering.
// Flush output buffer to ensure immediate streaming.
if ( ob_get_level() > 0 ) {
ob_end_flush();
}
flush();
try {
if ( $post_id > 0 ) {
$this->update_post_memory(
$post_id,
array(
'last_prompt' => $message,
'last_intent' => 'refine',
)
);
}
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) );
$post_config_context = $this->build_post_config_context( $post_config );
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
$language_instruction = $this->build_language_instruction( $effective_language, 'refined content' );
$refined_count = 0;
$total_cost = 0.0;
// Get post title for context
$post = get_post( $post_id );
$post_title = $post ? $post->post_title : 'Unknown';
// Normalize blocks for context
$context_blocks = array();
$block_source = is_array( $all_blocks ) && ! empty( $all_blocks ) ? $all_blocks : $this->select_blocks();
foreach ( $block_source as $block ) {
$client_id = $block['clientId'] ?? $block['attrs']['clientId'] ?? '';
$block_type = $block['name'] ?? $block['blockName'] ?? 'core/paragraph';
$block_attrs = $block['attributes'] ?? $block['attrs'] ?? array();
$content = $this->extract_block_content_from_attrs( $block_type, $block_attrs );
if ( empty( $client_id ) ) {
continue;
}
$context_blocks[] = array(
'clientId' => $client_id,
'type' => $block_type,
'content' => $content,
);
}
if ( $diff_plan && ! empty( $context_blocks ) ) {
$plan_prompt = "You are an editor planning precise block-level edits.
Return ONLY valid JSON in this format:
{
\"summary\": \"short summary\",
\"actions\": [
{\"action\": \"keep\", \"blockId\": \"...\"},
{\"action\": \"replace\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
{\"action\": \"insert_after\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
{\"action\": \"insert_before\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
{\"action\": \"delete\", \"blockId\": \"...\"},
{\"action\": \"change_type\", \"blockId\": \"...\", \"blockType\": \"core/list\", \"content\": \"...\"}
]
}
Rules:
- Keep actions minimal.
- Use blockId from the provided list only.
- If you need code, use blockType core/code and content with code only.
- For lists, use blockType core/list and content as one item per line.
- For headings, use blockType core/heading.
- For images, use blockType core/image and content as markdown image: ![alt](url).
- No explanations, no extra text, JSON only.
User request: {$message}
Blocks:
";
foreach ( $context_blocks as $index => $block ) {
$plan_prompt .= ($index + 1) . ". {$block['clientId']} | {$block['type']} | " . $block['content'] . "\n";
}
$plan_response = $provider->chat(
array(
array( 'role' => 'system', 'content' => $plan_prompt ),
array( 'role' => 'user', 'content' => 'Create the edit plan now.' ),
),
array( 'temperature' => 0.2 ),
'planning'
);
if ( ! is_wp_error( $plan_response ) ) {
// Track cost for edit plan generation
$plan_cost = $plan_response['cost'] ?? 0;
if ( $plan_cost > 0 ) {
do_action(
'wp_aw_after_api_request',
$post_id,
$plan_response['model'] ?? '',
'refinement_planning',
$plan_response['input_tokens'] ?? 0,
$plan_response['output_tokens'] ?? 0,
$plan_cost
);
}
$raw_content = trim( $plan_response['content'] );
error_log( 'WP Agentic Writer: Edit plan raw response: ' . substr( $raw_content, 0, 500 ) );
// Strip markdown code blocks if present (```json ... ```)
$json_content = $raw_content;
if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $raw_content, $matches ) ) {
$json_content = trim( $matches[1] );
error_log( 'WP Agentic Writer: Extracted JSON from markdown code block' );
}
$plan_json = json_decode( $json_content, true );
if ( is_array( $plan_json ) && isset( $plan_json['actions'] ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'edit_plan',
'plan' => $plan_json,
)
) . "\n\n";
flush();
exit;
} else {
error_log( 'WP Agentic Writer: Edit plan JSON decode failed or missing actions. JSON error: ' . json_last_error_msg() );
error_log( 'WP Agentic Writer: Attempted to parse: ' . substr( $json_content, 0, 200 ) );
}
} else {
error_log( 'WP Agentic Writer: Edit plan API error: ' . $plan_response->get_error_message() );
}
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => 'Failed to build an edit plan. The AI response was invalid. Please try a simpler instruction or use add below/above commands.',
)
) . "\n\n";
flush();
exit;
}
foreach ( $blocks_to_refine as $block_obj ) {
// Extract block data from the block object sent from frontend
$block_client_id = $block_obj['clientId'] ?? '';
$block_type = $block_obj['name'] ?? 'core/paragraph';
$block_attrs = $block_obj['attributes'] ?? array();
$block_content = $this->extract_block_content_from_attrs( $block_type, $block_attrs );
// Find block index in all blocks for context
$block_index = -1;
foreach ( $all_blocks as $i => $block ) {
if ( isset( $block['clientId'] ) && $block['clientId'] === $block_client_id ) {
$block_index = $i;
break;
}
}
// Build article context
$article_context = array(
'title' => $post_title,
'previousBlock' => $block_index > 0 ? $this->extract_heading_from_block( $all_blocks[ $block_index - 1 ] ) : null,
'nextBlock' => $block_index >= 0 && $block_index < count( $all_blocks ) - 1 ? $this->extract_heading_from_block( $all_blocks[ $block_index + 1 ] ) : null,
);
// Build refinement prompt
$memory_context = $this->get_post_memory_context( $post_id );
$context_str = "\n\nArticle Context:\n";
$context_str .= "Title: " . $post_title . "\n";
if ( ! empty( $article_context['previousBlock'] ) ) {
$context_str .= "Previous section: " . $article_context['previousBlock']['heading'] . "\n";
}
$context_str .= "Current block type: " . $block_type . "\n";
$context_str .= "Current content:\n" . $block_content . "\n";
if ( ! empty( $article_context['nextBlock'] ) ) {
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
}
$system_prompt = "You are an expert content refiner. Your task is to rewrite and improve content based on the user's request.
CRITICAL LANGUAGE REQUIREMENT:
{$language_instruction}
{$post_config_context}
{$context_str}
{$memory_context}
USER REQUEST: {$message}
IMPORTANT RULES:
1. Rewrite the content to fulfill the refinement request
2. Maintain the core meaning and key information
3. Ensure it flows well with surrounding sections
4. Match the article's overall tone and style
5. Return ONLY the refined content, no explanations or conversational text
Output format:
- If paragraph: Return the refined text only
- If heading: Return the refined heading text only
- If list: Return the list items, one per line
- No markdown formatting like ```text``` wrappers
- No conversational filler
- Start directly with the refined content";
$messages = array(
array(
'role' => 'system',
'content' => $system_prompt,
),
array(
'role' => 'user',
'content' => "Refine this content.",
),
);
// Use streaming for real-time feedback
$refined_content = '';
$stream_result = $provider->chat_stream(
$messages,
array( 'temperature' => 0.7 ),
'execution',
function( $chunk ) use ( &$refined_content ) {
// Accumulate the streaming content
$refined_content .= $chunk;
}
);
if ( is_wp_error( $stream_result ) ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $stream_result->get_error_message(),
)
) . "\n\n";
flush();
continue;
}
// Track cost from streaming result (always track for debugging).
$stream_cost = $stream_result['cost'] ?? 0;
$total_cost += $stream_cost;
do_action(
'wp_aw_after_api_request',
$post_id,
$stream_result['model'] ?? '',
'block_refinement',
$stream_result['input_tokens'] ?? 0,
$stream_result['output_tokens'] ?? 0,
$stream_cost
);
// Parse and clean the response
$payload = $this->parse_refined_payload( $refined_content );
$refined_content = $this->clean_refined_content( $payload['content'] );
$resolved_block_type = $payload['blockType'] ?? $block_type;
// Create proper block structure
$block_structure = $this->create_block_structure( $block_client_id, $resolved_block_type, $refined_content );
// Send the refined block
echo "data: " . wp_json_encode(
array(
'type' => 'block',
'block' => $block_structure,
)
) . "\n\n";
flush();
$refined_count++;
// Small delay between blocks
usleep( 100000 );
}
// Send completion message
echo "data: " . wp_json_encode(
array(
'type' => 'complete',
'refined' => $refined_count,
'totalCost' => $total_cost,
)
) . "\n\n";
flush();
} catch ( Exception $e ) {
echo "data: " . wp_json_encode(
array(
'type' => 'error',
'message' => $e->getMessage(),
)
) . "\n\n";
flush();
}
}
/**
* Find block by client ID in parsed blocks array.
*
* @since 0.1.0
* @param array $blocks Parsed blocks array.
* @param string $client_id Block client ID to find.
* @return array|null Block data or null if not found.
*/
private function find_block_by_client_id( $blocks, $client_id ) {
foreach ( $blocks as $block ) {
if ( isset( $block['attrs']['clientId'] ) && $block['attrs']['clientId'] === $client_id ) {
return $block;
}
// Check inner blocks
if ( isset( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) {
$found = $this->find_block_by_client_id( $block['innerBlocks'], $client_id );
if ( $found ) {
return $found;
}
}
}
return null;
}
/**
* Find block index in parsed blocks array.
*
* @since 0.1.0
* @param array $blocks Parsed blocks array.
* @param string $client_id Block client ID to find.
* @return int Block index or -1 if not found.
*/
private function find_block_index( $blocks, $client_id ) {
foreach ( $blocks as $index => $block ) {
if ( isset( $block['attrs']['clientId'] ) && $block['attrs']['clientId'] === $client_id ) {
return $index;
}
}
return -1;
}
/**
* Extract content from block data.
*
* @since 0.1.0
* @param array $block Block data.
* @return string Block content.
*/
private function extract_block_content( $block ) {
if ( isset( $block['attrs']['content'] ) ) {
return $block['attrs']['content'];
}
if ( isset( $block['innerHTML'] ) ) {
// Strip HTML tags for plain content
return wp_strip_all_tags( $block['innerHTML'] );
}
return '';
}
/**
* Extract heading from block.
*
* @since 0.1.0
* @param array $block Block data.
* @return array|null Heading data or null if not a heading.
*/
private function extract_heading_from_block( $block ) {
if ( 'core/heading' === $block['blockName'] && isset( $block['attrs']['content'] ) ) {
return array(
'heading' => $block['attrs']['content'],
);
}
return null;
}
/**
* Clean refined content by removing conversational text.
*
* @since 0.1.0
* @param string $content Content to clean.
* @return string Cleaned content.
*/
private function clean_refined_content( $content ) {
// Remove common conversational prefixes
$conversational_prefixes = array(
'Certainly! Here\'s',
'Here\'s',
'The refined content',
'Here is the',
'Below is the',
'Okay, here',
'Sure, here',
);
foreach ( $conversational_prefixes as $prefix ) {
if ( stripos( $content, $prefix ) === 0 ) {
$content = substr( $content, strlen( $prefix ) );
$content = ltrim( $content, ":\n\r " );
}
}
// Remove markdown code blocks if present
$content = preg_replace( '/^```(?:text|markdown)?\n*/i', '', $content );
$content = preg_replace( '/```*$/i', '', $content );
$content = trim( $content );
return $content;
}
/**
* Parse refined payload that may be wrapped in JSON.
*
* @since 0.1.0
* @param string $content Raw model content.
* @return array Parsed payload with content and optional blockType.
*/
private function parse_refined_payload( $content ) {
$payload = array(
'content' => $content,
);
if ( ! is_string( $content ) ) {
return $payload;
}
$trimmed = trim( $content );
if ( '' === $trimmed ) {
return $payload;
}
if ( $trimmed[0] !== '{' || substr( $trimmed, -1 ) !== '}' ) {
return $payload;
}
$decoded = json_decode( $trimmed, true );
if ( ! is_array( $decoded ) ) {
return $payload;
}
if ( isset( $decoded['content'] ) && is_string( $decoded['content'] ) ) {
$payload['content'] = $decoded['content'];
}
$block_type = $decoded['blockType'] ?? $decoded['type'] ?? null;
if ( is_string( $block_type ) && 0 === strpos( $block_type, 'core/' ) ) {
$payload['blockType'] = $block_type;
}
return $payload;
}
/**
* Create block structure for refined content.
*
* @since 0.1.0
* @param string $block_id Block client ID.
* @param string $block_type Block type.
* @param string $content Refined content.
* @return array Block structure.
*/
private function create_block_structure( $block_id, $block_type, $content ) {
if ( preg_match( '/^!\\[(.*?)\\]\\(([^\\s)]+)(?:\\s+\\"[^\\"]*\\")?\\)\\s*$/', trim( $content ), $matches ) ) {
$alt = trim( $matches[1] );
$url = trim( $matches[2] );
$escaped_alt = esc_attr( $alt );
$escaped_url = esc_url( $url );
return array(
'blockName' => 'core/image',
'attrs' => array(
'id' => 0,
'url' => $escaped_url,
'alt' => $alt,
'caption' => '',
'sizeSlug' => 'large',
'linkDestination' => 'none',
),
'innerHTML' => '<figure class="wp-block-image size-large"><img src="' . $escaped_url . '" alt="' . $escaped_alt . '" /></figure>',
'clientId' => $block_id,
);
}
if ( 'core/paragraph' === $block_type ) {
return array(
'blockName' => 'core/paragraph',
'attrs' => array( 'content' => $content ),
'innerHTML' => '<p>' . $content . '</p>',
'clientId' => $block_id,
);
} elseif ( 'core/heading' === $block_type ) {
// Detect heading level
$level = 2;
if ( preg_match( '/^(#{1,6})\s/', $content ) ) {
$count = strspn( $content, '#' );
$level = min( $count, 6 );
$content = trim( substr( $content, $count ) );
}
$tag = 'h' . $level;
return array(
'blockName' => 'core/heading',
'attrs' => array(
'level' => $level,
'content' => $content,
),
'innerHTML' => "<{$tag}>{$content}</{$tag}>",
'clientId' => $block_id,
);
} elseif ( 'core/list' === $block_type ) {
$lines = explode( "\n", $content );
$lines = array_filter( array_map( 'trim', $lines ) );
// Create inner blocks for list items
$inner_blocks = array();
foreach ( $lines as $line ) {
$inner_blocks[] = array(
'blockName' => 'core/list-item',
'attrs' => array( 'content' => $line ),
'innerHTML' => '<li>' . $line . '</li>',
);
}
return array(
'blockName' => 'core/list',
'attrs' => array( 'ordered' => false ),
'innerBlocks' => $inner_blocks,
'clientId' => $block_id,
);
} elseif ( 'core/code' === $block_type ) {
$language = 'text';
$code_content = $content;
if ( preg_match( '/^```(\\w+)?\\s*/', $content, $matches ) ) {
if ( ! empty( $matches[1] ) ) {
$language = $matches[1];
}
$code_content = preg_replace( '/^```\\w*\\s*/', '', $code_content );
$code_content = preg_replace( '/```\\s*$/', '', $code_content );
$code_content = trim( $code_content );
}
$escaped = htmlspecialchars( $code_content, ENT_NOQUOTES, 'UTF-8' );
return array(
'blockName' => 'core/code',
'attrs' => array(
'language' => $language,
'content' => $code_content,
),
'innerHTML' => '<pre class="wp-block-code"><code>' . $escaped . '</code></pre>',
'clientId' => $block_id,
);
}
// Fallback to paragraph
return array(
'blockName' => 'core/paragraph',
'attrs' => array( 'content' => $content ),
'innerHTML' => '<p>' . $content . '</p>',
'clientId' => $block_id,
);
}
/**
* Build a short memory summary from the plan JSON.
*
* @since 0.1.0
* @param array $plan_json Plan data.
* @return string Summary text.
*/
private function build_memory_summary_from_plan( $plan_json ) {
if ( empty( $plan_json ) || ! is_array( $plan_json ) ) {
return '';
}
$title = $plan_json['title'] ?? '';
$headings = array();
if ( ! empty( $plan_json['sections'] ) && is_array( $plan_json['sections'] ) ) {
foreach ( $plan_json['sections'] as $section ) {
if ( ! empty( $section['heading'] ) ) {
$headings[] = $section['heading'];
}
}
}
$summary = '';
if ( $title ) {
$summary .= "Title: {$title}\n";
}
if ( ! empty( $headings ) ) {
$summary .= 'Sections: ' . implode( ' | ', $headings );
}
return trim( $summary );
}
/**
* Update per-post memory meta.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @param array $data Memory fields to update.
* @return void
*/
private function update_post_memory( $post_id, $data ) {
if ( $post_id <= 0 ) {
return;
}
$memory = get_post_meta( $post_id, '_wpaw_memory', true );
if ( ! is_array( $memory ) ) {
$memory = array();
}
$memory = array_merge( $memory, $data );
$memory['updated_at'] = current_time( 'timestamp' );
update_post_meta( $post_id, '_wpaw_memory', $memory );
}
/**
* Build memory context string for prompts.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @return string Context string.
*/
private function get_post_memory_context( $post_id ) {
if ( $post_id <= 0 ) {
return '';
}
$memory = get_post_meta( $post_id, '_wpaw_memory', true );
if ( empty( $memory ) || ! is_array( $memory ) ) {
return '';
}
$lines = array();
if ( ! empty( $memory['summary'] ) ) {
$lines[] = 'Summary: ' . $memory['summary'];
}
if ( ! empty( $memory['last_prompt'] ) ) {
$lines[] = 'Last prompt: ' . $memory['last_prompt'];
}
if ( ! empty( $memory['last_intent'] ) ) {
$lines[] = 'Last intent: ' . $memory['last_intent'];
}
if ( empty( $lines ) ) {
return '';
}
return "\n\n=== POST MEMORY ===\n" . implode( "\n", $lines ) . "\n=== END POST MEMORY ===\n";
}
/**
* Get blocks from the current editor state.
*
* @since 0.1.0
* @return array Array of block objects from editor.
*/
private function select_blocks() {
// Get blocks from the editor via REST API request
// This is a helper to simulate wp.data.select( 'core/block-editor' ).getBlocks()
global $post;
if ( ! $post ) {
return array();
}
// Parse blocks from post content
$blocks = parse_blocks( $post->post_content );
// Filter out empty blocks
return array_filter( $blocks, function( $block ) {
return ! empty( $block['blockName'] );
} );
}
/**
* Serialize block object for consistent handling.
*
* @since 0.1.0
* @param array $block Block data.
* @return array Serialized block with clientId.
*/
private function serialize_block( $block ) {
// Ensure clientId is set in attrs
if ( ! isset( $block['attrs']['clientId'] ) ) {
$block['attrs']['clientId'] = isset( $block['clientId'] ) ? $block['clientId'] : uniqid();
}
return $block;
}
/**
* Extract content from block attributes.
*
* @since 0.1.0
* @param string $block_type Block type (e.g., 'core/paragraph').
* @param array $attrs Block attributes.
* @return string Extracted content.
*/
private function extract_block_content_from_attrs( $block_type, $attrs ) {
switch ( $block_type ) {
case 'core/paragraph':
return isset( $attrs['content'] ) ? $attrs['content'] : '';
case 'core/heading':
return isset( $attrs['content'] ) ? $attrs['content'] : '';
case 'core/list':
// For lists, return a string representation
if ( isset( $attrs['values'] ) && is_array( $attrs['values'] ) ) {
return implode( "\n", $attrs['values'] );
}
return '';
case 'core/code':
return isset( $attrs['content'] ) ? $attrs['content'] : '';
case 'core/image':
if ( isset( $attrs['url'] ) && isset( $attrs['alt'] ) ) {
return '![' . $attrs['alt'] . '](' . $attrs['url'] . ')';
}
return '';
default:
// Try to get content from common attributes
if ( isset( $attrs['content'] ) ) {
return $attrs['content'];
}
if ( isset( $attrs['value'] ) ) {
return $attrs['value'];
}
return '';
}
}
/**
* Handle SEO audit request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_seo_audit( $request ) {
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
if ( $post_id <= 0 ) {
return new WP_Error(
'invalid_post',
__( 'Invalid post ID.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error(
'post_not_found',
__( 'Post not found.', 'wp-agentic-writer' ),
array( 'status' => 404 )
);
}
$post_config = $this->get_post_config( $post_id );
$content = wp_strip_all_tags( $post->post_content );
$title = $post->post_title;
$focus_keyword = $post_config['seo_focus_keyword'] ?? '';
$audit = array(
'score' => 0,
'checks' => array(),
'keyword_density' => 0,
'word_count' => 0,
);
// Word count
$word_count = str_word_count( $content );
$audit['word_count'] = $word_count;
// Check 1: Content length
if ( $word_count >= 1500 ) {
$audit['checks'][] = array( 'name' => 'Content length', 'status' => 'good', 'message' => "Excellent! {$word_count} words (recommended: 1500+)" );
$audit['score'] += 15;
} elseif ( $word_count >= 800 ) {
$audit['checks'][] = array( 'name' => 'Content length', 'status' => 'ok', 'message' => "Good: {$word_count} words (recommended: 1500+)" );
$audit['score'] += 10;
} else {
$audit['checks'][] = array( 'name' => 'Content length', 'status' => 'warning', 'message' => "Short: {$word_count} words (recommended: 800+)" );
$audit['score'] += 5;
}
// Check 2: Focus keyword presence
if ( ! empty( $focus_keyword ) ) {
$keyword_count = substr_count( strtolower( $content ), strtolower( $focus_keyword ) );
$keyword_density = $word_count > 0 ? round( ( $keyword_count / $word_count ) * 100, 2 ) : 0;
$audit['keyword_density'] = $keyword_density;
// Keyword in title
if ( stripos( $title, $focus_keyword ) !== false ) {
$audit['checks'][] = array( 'name' => 'Keyword in title', 'status' => 'good', 'message' => 'Focus keyword found in title' );
$audit['score'] += 20;
} else {
$audit['checks'][] = array( 'name' => 'Keyword in title', 'status' => 'warning', 'message' => 'Focus keyword not found in title' );
}
// Keyword density
if ( $keyword_density >= 1 && $keyword_density <= 2.5 ) {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'good', 'message' => "Optimal: {$keyword_density}% (target: 1-2.5%)" );
$audit['score'] += 20;
} elseif ( $keyword_density > 0 && $keyword_density < 1 ) {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'ok', 'message' => "Low: {$keyword_density}% (target: 1-2.5%)" );
$audit['score'] += 10;
} elseif ( $keyword_density > 2.5 ) {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'warning', 'message' => "High: {$keyword_density}% - may be over-optimized" );
$audit['score'] += 5;
} else {
$audit['checks'][] = array( 'name' => 'Keyword density', 'status' => 'error', 'message' => 'Focus keyword not found in content' );
}
// Keyword in first paragraph
$first_para = substr( $content, 0, 500 );
if ( stripos( $first_para, $focus_keyword ) !== false ) {
$audit['checks'][] = array( 'name' => 'Keyword in intro', 'status' => 'good', 'message' => 'Focus keyword in first paragraph' );
$audit['score'] += 15;
} else {
$audit['checks'][] = array( 'name' => 'Keyword in intro', 'status' => 'warning', 'message' => 'Add focus keyword to first paragraph' );
}
} else {
$audit['checks'][] = array( 'name' => 'Focus keyword', 'status' => 'warning', 'message' => 'No focus keyword set' );
}
// Check 3: Headings
$heading_count = preg_match_all( '/<!-- wp:heading.*?-->/', $post->post_content, $matches );
if ( $heading_count >= 3 ) {
$audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'good', 'message' => "{$heading_count} subheadings found" );
$audit['score'] += 15;
} elseif ( $heading_count >= 1 ) {
$audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'ok', 'message' => "Only {$heading_count} subheading(s) - add more for readability" );
$audit['score'] += 8;
} else {
$audit['checks'][] = array( 'name' => 'Subheadings', 'status' => 'warning', 'message' => 'No subheadings found - add H2/H3 headings' );
}
// Check 4: Images
$image_count = preg_match_all( '/<!-- wp:image.*?-->/', $post->post_content, $matches );
if ( $image_count >= 1 ) {
$audit['checks'][] = array( 'name' => 'Images', 'status' => 'good', 'message' => "{$image_count} image(s) found" );
$audit['score'] += 10;
} else {
$audit['checks'][] = array( 'name' => 'Images', 'status' => 'ok', 'message' => 'No images - consider adding visuals' );
}
// Check 5: Meta description
$meta_desc = $post_config['seo_meta_description'] ?? '';
if ( ! empty( $meta_desc ) ) {
$meta_len = strlen( $meta_desc );
if ( $meta_len >= 120 && $meta_len <= 160 ) {
$audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'good', 'message' => "Perfect length: {$meta_len} chars (120-160)" );
$audit['score'] += 5;
} elseif ( $meta_len > 0 ) {
$audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'ok', 'message' => "Length: {$meta_len} chars (optimal: 120-160)" );
$audit['score'] += 3;
}
} else {
$audit['checks'][] = array( 'name' => 'Meta description', 'status' => 'warning', 'message' => 'No meta description set' );
}
// Cap score at 100
$audit['score'] = min( 100, $audit['score'] );
return new WP_REST_Response( $audit, 200 );
}
/**
* Suggest relevant internal links based on content similarity.
*
* @since 0.1.0
* @param int $post_id Current post ID.
* @param string $focus_keyword Focus keyword.
* @param int $limit Maximum number of suggestions.
* @return array Array of suggested posts with title and URL.
*/
private function suggest_internal_links( $post_id, $focus_keyword = '', $limit = 3 ) {
$suggestions = array();
// Get all published posts except current
$args = array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 50,
'post__not_in' => array( $post_id ),
'orderby' => 'date',
'order' => 'DESC',
);
$posts = get_posts( $args );
if ( empty( $posts ) ) {
return $suggestions;
}
foreach ( $posts as $post ) {
// Skip if this is the current post (safety check)
if ( $post->ID === $post_id ) {
continue;
}
$score = 0;
// 1. Same category (weight: 30 points per category)
$current_cats = wp_get_post_categories( $post_id );
$post_cats = wp_get_post_categories( $post->ID );
$cat_overlap = count( array_intersect( $current_cats, $post_cats ) );
$score += $cat_overlap * 30;
// 2. Same tags (weight: 20 points per tag)
$current_tags = wp_get_post_tags( $post_id, array( 'fields' => 'ids' ) );
$post_tags = wp_get_post_tags( $post->ID, array( 'fields' => 'ids' ) );
$tag_overlap = count( array_intersect( $current_tags, $post_tags ) );
$score += $tag_overlap * 20;
// 3. Focus keyword in title (weight: 25 points)
if ( ! empty( $focus_keyword ) && stripos( $post->post_title, $focus_keyword ) !== false ) {
$score += 25;
}
// 4. Focus keyword in content (weight: 15 points)
if ( ! empty( $focus_keyword ) && stripos( $post->post_content, $focus_keyword ) !== false ) {
$score += 15;
}
// 5. Recency bonus (weight: 10 points for posts < 30 days, 5 points for < 90 days)
$days_old = ( time() - strtotime( $post->post_date ) ) / DAY_IN_SECONDS;
if ( $days_old < 30 ) {
$score += 10;
} elseif ( $days_old < 90 ) {
$score += 5;
}
if ( $score > 0 ) {
$suggestions[] = array(
'id' => $post->ID,
'title' => $post->post_title,
'url' => get_permalink( $post->ID ),
'score' => $score,
);
}
}
// Sort by score descending
usort(
$suggestions,
function ( $a, $b ) {
return $b['score'] - $a['score'];
}
);
return array_slice( $suggestions, 0, $limit );
}
/**
* Auto-generate meta description after article execution.
*
* @since 0.1.0
* @param int $post_id Post ID.
* @param array $post_config Post configuration.
* @param string $effective_language Effective language.
* @return array|WP_Error Result with meta description and cost, or error.
*/
private function auto_generate_meta_description( $post_id, $post_config, $effective_language ) {
$post = get_post( $post_id );
if ( ! $post ) {
return new WP_Error( 'invalid_post', 'Post not found' );
}
$content = wp_strip_all_tags( $post->post_content );
$title = $post->post_title;
$focus_keyword = $post_config['seo_focus_keyword'] ?? '';
if ( empty( $content ) ) {
return new WP_Error( 'no_content', 'No content available' );
}
$language_instruction = $this->build_language_instruction( $effective_language, 'meta description' );
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
$prompt .= "- Include a call-to-action or value proposition\n";
$prompt .= "- Make it enticing for searchers to click\n";
if ( ! empty( $focus_keyword ) ) {
$prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n";
}
$prompt .= "\n{$language_instruction}\n";
$prompt .= "\nTitle: {$title}\n";
$prompt .= "\nContent summary (first 500 chars):\n" . substr( $content, 0, 500 );
$prompt .= "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation.";
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'clarity' );
if ( is_wp_error( $response ) ) {
return $response;
}
$meta_description = trim( $response['content'] ?? '' );
$meta_description = preg_replace( '/^["\']|["\']$/', '', $meta_description );
// Enforce 155 character limit
if ( strlen( $meta_description ) > 155 ) {
$meta_description = substr( $meta_description, 0, 152 ) . '...';
}
// Save to post meta
update_post_meta( $post_id, '_wpaw_meta_description', $meta_description );
// Track cost
$cost = $response['cost'] ?? 0;
if ( $cost > 0 ) {
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? 'unknown',
'meta_description',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$cost
);
}
return array(
'meta_description' => $meta_description,
'length' => strlen( $meta_description ),
'cost' => $cost,
);
}
/**
* Handle generate meta description request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_generate_meta( $request ) {
$params = $request->get_json_params();
$post_id = $params['postId'] ?? 0;
$content = $params['content'] ?? '';
$title = $params['title'] ?? '';
$focus_keyword = $params['focusKeyword'] ?? '';
$chat_history = $params['chatHistory'] ?? array();
if ( empty( $content ) && $post_id > 0 ) {
$post = get_post( $post_id );
if ( $post ) {
$content = wp_strip_all_tags( $post->post_content );
$title = $post->post_title;
}
}
if ( empty( $content ) ) {
return new WP_Error(
'no_content',
__( 'No content available to generate meta description.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Get detected language from post meta
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$post_config = $this->get_post_config( $post_id );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
$language_instruction = $this->build_language_instruction( $effective_language, 'meta description' );
// Build chat history context if available
$chat_context = '';
if ( ! empty( $chat_history ) && is_array( $chat_history ) ) {
$chat_context = "\n\nOriginal discussion context:\n";
$user_messages = array_filter( $chat_history, function( $msg ) {
return isset( $msg['role'] ) && 'user' === strtolower( $msg['role'] );
});
$recent_user = array_slice( $user_messages, -2 );
foreach ( $recent_user as $msg ) {
$content_text = $msg['content'] ?? '';
if ( ! empty( $content_text ) ) {
$chat_context .= "- " . substr( $content_text, 0, 100 ) . "\n";
}
}
}
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
$prompt .= "- Include a call-to-action or value proposition\n";
$prompt .= "- Make it enticing for searchers to click\n";
if ( ! empty( $focus_keyword ) ) {
$prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n";
}
$prompt .= "\n{$language_instruction}\n";
$prompt .= $chat_context;
$prompt .= "\nTitle: {$title}\n";
$prompt .= "\nContent summary (first 500 chars):\n" . substr( $content, 0, 500 );
$prompt .= "\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation.";
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array(), 'clarity' );
if ( is_wp_error( $response ) ) {
return $response;
}
$meta_description = trim( $response['content'] ?? '' );
$meta_description = preg_replace( '/^["\']|["\']$/', '', $meta_description );
// Enforce 155 character limit
if ( strlen( $meta_description ) > 155 ) {
$meta_description = substr( $meta_description, 0, 152 ) . '...';
}
// Track cost for meta description generation
$cost = $response['cost'] ?? 0;
if ( $cost > 0 && $post_id > 0 ) {
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? 'unknown',
'meta_description',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$cost
);
}
return new WP_REST_Response(
array(
'meta_description' => $meta_description,
'length' => strlen( $meta_description ),
'cost' => $cost,
),
200
);
}
/**
* Handle suggest keywords request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_suggest_keywords( $request ) {
$params = $request->get_json_params();
$post_id = $params['postId'] ?? 0;
$title = $params['title'] ?? '';
$sections = $params['sections'] ?? array();
if ( empty( $title ) || empty( $sections ) ) {
return new WP_Error(
'missing_data',
__( 'Title and sections are required for keyword suggestions.', 'wp-agentic-writer' ),
array( 'status' => 400 )
);
}
// Get detected language from post meta or config
$stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true );
$post_config = $this->get_post_config( $post_id );
$effective_language = $this->resolve_language_preference( $post_config, $stored_language );
// Use keyword suggester helper
$result = WP_Agentic_Writer_Keyword_Suggester::suggest_keywords(
$title,
$sections,
$effective_language,
$post_id
);
if ( is_wp_error( $result ) ) {
return $result;
}
return new WP_REST_Response(
array(
'focus_keyword' => $result['focus_keyword'],
'secondary_keywords' => $result['secondary_keywords'],
'reasoning' => $result['reasoning'],
'cost' => $result['cost'],
),
200
);
}
/**
* Handle context summarization request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_summarize_context( $request ) {
$params = $request->get_json_params();
$chat_history = $params['chatHistory'] ?? array();
$post_id = $params['postId'] ?? 0;
// Short history doesn't need summarization
if ( empty( $chat_history ) || count( $chat_history ) < 4 ) {
return new WP_REST_Response(
array(
'summary' => '',
'use_full_history' => true,
'cost' => 0,
'tokens_saved' => 0,
),
200
);
}
// Build history text
$history_text = '';
foreach ( $chat_history as $msg ) {
$role = ucfirst( $msg['role'] ?? 'Unknown' );
$content = $msg['content'] ?? '';
if ( ! empty( $content ) ) {
$history_text .= "{$role}: {$content}\n\n";
}
}
// Build summarization prompt
$prompt = "Summarize this conversation into key points that capture the user's intent and requirements.
Focus on:
- Main topic
- Specific focus areas
- Rejected/excluded topics
- User preferences (tone, audience, etc.)
Keep the summary concise (max 200 words) but preserve critical context.
Write in the same language as the conversation.
Output format:
TOPIC: [main topic]
FOCUS: [what to include]
EXCLUDE: [what to avoid]
PREFERENCES: [any specific requirements]
Conversation:
{$history_text}";
// Call AI with cheap model
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array(), 'summarize' );
if ( is_wp_error( $response ) ) {
return $response;
}
// Calculate tokens saved
$original_tokens = count( $chat_history ) * 500; // Rough estimate
$summary_tokens = $response['output_tokens'] ?? 100;
$tokens_saved = $original_tokens - $summary_tokens;
// Track cost
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'summarize_context',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
return new WP_REST_Response(
array(
'summary' => $response['content'] ?? '',
'use_full_history' => false,
'cost' => $response['cost'] ?? 0,
'tokens_saved' => $tokens_saved,
),
200
);
}
/**
* Handle intent detection request.
*
* @since 0.1.0
* @param WP_REST_Request $request REST request.
* @return WP_REST_Response|WP_Error Response.
*/
public function handle_detect_intent( $request ) {
$params = $request->get_json_params();
$last_message = $params['lastMessage'] ?? '';
$has_plan = $params['hasPlan'] ?? false;
$current_mode = $params['currentMode'] ?? 'chat';
$post_id = $params['postId'] ?? 0;
if ( empty( $last_message ) ) {
return new WP_REST_Response(
array( 'intent' => 'continue_chat' ),
200
);
}
// Build intent detection prompt
$has_plan_str = $has_plan ? 'true' : 'false';
$prompt = "Based on the user's message, determine their intent. Choose ONE:
1. \"create_outline\" - User wants to create an article outline/structure
2. \"start_writing\" - User wants to write the full article
3. \"refine_content\" - User wants to improve existing content
4. \"continue_chat\" - User wants to continue discussing/exploring
5. \"clarify\" - User is asking questions or needs clarification
Consider:
- The user's explicit request
- Whether they have an outline already (has_plan: {$has_plan_str})
- Current mode (current_mode: {$current_mode})
User's message: \"{$last_message}\"
Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
// Call AI with cheap model
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$messages = array(
array(
'role' => 'user',
'content' => $prompt,
),
);
$response = $provider->chat( $messages, array(), 'intent_detection' );
if ( is_wp_error( $response ) ) {
return $response;
}
// Track cost
do_action(
'wp_aw_after_api_request',
$post_id,
$response['model'] ?? '',
'detect_intent',
$response['input_tokens'] ?? 0,
$response['output_tokens'] ?? 0,
$response['cost'] ?? 0
);
// Clean up response
$intent = trim( strtolower( $response['content'] ?? 'continue_chat' ) );
$intent = str_replace( '"', '', $intent );
// Validate intent
$valid_intents = array( 'create_outline', 'start_writing', 'refine_content', 'continue_chat', 'clarify' );
if ( ! in_array( $intent, $valid_intents, true ) ) {
$intent = 'continue_chat';
}
return new WP_REST_Response(
array(
'intent' => $intent,
'cost' => $response['cost'] ?? 0,
),
200
);
}
}