controller_writing = new WP_Agentic_Writer_Controller_Writing( $this, ); $this->controller_refinement = new WP_Agentic_Writer_Controller_Refinement( $this, ); $this->controller_image = new WP_Agentic_Writer_Controller_Image($this); $this->controller_seo = new WP_Agentic_Writer_Controller_Seo($this); } /** * Enqueue block assets (inside the editor canvas iframe). * * @since 0.3.0 */ public function enqueue_block_assets() { if (!is_admin()) { return; } $editor_style_url = WP_AGENTIC_WRITER_URL . "assets/css/editor.css"; $editor_style_path = WP_AGENTIC_WRITER_DIR . "assets/css/editor.css"; wp_enqueue_style( "wp-agentic-writer-editor", $editor_style_url, [], file_exists($editor_style_path) ? filemtime($editor_style_path) : WP_AGENTIC_WRITER_VERSION, ); } /** * 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/dist/sidebar.js"; $style_url = WP_AGENTIC_WRITER_URL . "assets/css/agentic-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"; // Enqueue markdown renderer and sanitizer. wp_enqueue_script( "wp-agentic-writer-markdown-it", $markdown_it_url, [], "13.0.2", true, ); wp_enqueue_script( "wp-agentic-writer-dompurify", $dompurify_url, [], "3.0.8", true, ); wp_enqueue_script( "wp-agentic-writer-markdown-task-lists", $markdown_task_lists_url, ["wp-agentic-writer-markdown-it"], "2.1.1", true, ); // Enqueue utility functions (loaded before main sidebar). $utils_script_path = WP_AGENTIC_WRITER_DIR . "assets/js/sidebar-utils.js"; wp_enqueue_script( "wp-agentic-writer-sidebar-utils", WP_AGENTIC_WRITER_URL . "assets/js/sidebar-utils.js", [], file_exists($utils_script_path) ? filemtime($utils_script_path) : WP_AGENTIC_WRITER_VERSION, true, ); // Enqueue sidebar script. $script_path = WP_AGENTIC_WRITER_DIR . "assets/js/dist/sidebar.js"; wp_enqueue_script( "wp-agentic-writer-sidebar", $script_url, [ "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", "wp-agentic-writer-sidebar-utils", ], 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", [ "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 image block toolbar script. $block_image_script_path = WP_AGENTIC_WRITER_DIR . "assets/js/block-image-generate.js"; wp_enqueue_script( "wp-agentic-writer-block-image-generate", WP_AGENTIC_WRITER_URL . "assets/js/block-image-generate.js", [ "wp-block-editor", "wp-components", "wp-compose", "wp-data", "wp-element", "wp-hooks", "wp-i18n", ], file_exists($block_image_script_path) ? filemtime($block_image_script_path) : WP_AGENTIC_WRITER_VERSION, true, ); // Enqueue image modal script. $image_modal_script_path = WP_AGENTIC_WRITER_DIR . "assets/js/image-modal.js"; wp_enqueue_script( "wp-agentic-writer-image-modal", WP_AGENTIC_WRITER_URL . "assets/js/image-modal.js", ["wp-components", "wp-element", "wp-data", "wp-block-editor"], file_exists($image_modal_script_path) ? filemtime($image_modal_script_path) : WP_AGENTIC_WRITER_VERSION, true, ); // Enqueue Agentic Design System tokens (Stitch — Phase 0). $tokens_style_path = WP_AGENTIC_WRITER_DIR . "assets/css/agentic-tokens.css"; wp_enqueue_style( "wp-agentic-writer-tokens", WP_AGENTIC_WRITER_URL . "assets/css/agentic-tokens.css", [], file_exists($tokens_style_path) ? filemtime($tokens_style_path) : WP_AGENTIC_WRITER_VERSION, ); // Enqueue sidebar styles. $style_path = WP_AGENTIC_WRITER_DIR . "assets/css/agentic-sidebar.css"; wp_enqueue_style( "wp-agentic-writer-sidebar", $style_url, ["wp-agentic-writer-tokens"], file_exists($style_path) ? filemtime($style_path) : WP_AGENTIC_WRITER_VERSION, ); // Enqueue sidebar dark theme overrides (Stitch design system). $dark_style_path = WP_AGENTIC_WRITER_DIR . "assets/css/agentic-sidebar-dark.css"; wp_enqueue_style( "wp-agentic-writer-sidebar-dark", WP_AGENTIC_WRITER_URL . "assets/css/agentic-sidebar-dark.css", ["wp-agentic-writer-sidebar"], file_exists($dark_style_path) ? filemtime($dark_style_path) : WP_AGENTIC_WRITER_VERSION, ); // Enqueue agentic components styles. $components_style_path = WP_AGENTIC_WRITER_DIR . "assets/css/agentic-components.css"; $components_style_url = WP_AGENTIC_WRITER_URL . "assets/css/agentic-components.css"; wp_enqueue_style( "wp-agentic-writer-components", $components_style_url, [], file_exists($components_style_path) ? filemtime($components_style_path) : WP_AGENTIC_WRITER_VERSION, ); // Enqueue workflow styles. $workflow_style_path = WP_AGENTIC_WRITER_DIR . "assets/css/agentic-workflow.css"; $workflow_style_url = WP_AGENTIC_WRITER_URL . "assets/css/agentic-workflow.css"; wp_enqueue_style( "wp-agentic-writer-workflow", $workflow_style_url, [], file_exists($workflow_style_path) ? filemtime($workflow_style_path) : WP_AGENTIC_WRITER_VERSION, ); // Removed redundant editor styles enqueue from here, moved to enqueue_block_assets // 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(); // Health check: verify DB table and API key exist $health = $this->run_health_check(); // Localize script with data. $data = [ "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__)), "health" => $health, ]; wp_localize_script( "wp-agentic-writer-sidebar", "wpAgenticWriter", $data, ); } /** * Run health check for sidebar initialization. * * @since 0.2.4 * @return array Health status. */ private function run_health_check() { global $wpdb; $table_name = $wpdb->prefix . "wpaw_conversations"; $table_exists = $wpdb->get_var( $wpdb->prepare("SHOW TABLES LIKE %s", $table_name), ) === $table_name; $settings = get_option("wp_agentic_writer_settings", []); $has_api_key = !empty($settings["openrouter_api_key"]); $issues = []; if (!$table_exists) { $issues[] = [ "type" => "db_table_missing", "message" => "Conversation table not found. Please deactivate and reactivate the plugin.", ]; } if (!$has_api_key) { $issues[] = [ "type" => "no_api_key", "message" => "API key not configured. Add your OpenRouter key in settings.", "actionUrl" => admin_url( "options-general.php?page=wp-agentic-writer-settings", ), "actionLabel" => "Open Settings", ]; } return [ "ok" => empty($issues), "issues" => $issues, ]; } /** * Get settings for JavaScript. * * @since 0.1.0 * @return array Settings. */ private function get_settings_for_js() { $settings = get_option("wp_agentic_writer_settings", []); // Don't expose API key to frontend. unset($settings["openrouter_api_key"]); // Ensure all required keys exist with defaults from model registry. $defaults = [ "chat_model" => WPAW_Model_Registry::get_default_model("chat"), "clarity_model" => WPAW_Model_Registry::get_default_model( "clarity", ), "planning_model" => WPAW_Model_Registry::get_default_model( "planning", ), "writing_model" => WPAW_Model_Registry::get_default_model( "writing", ), "refinement_model" => WPAW_Model_Registry::get_default_model( "refinement", ), "image_model" => WPAW_Model_Registry::get_default_model("image"), "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-settings", ), "preferred_languages" => ["auto", "English", "Indonesian"], "custom_languages" => [], ]; 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", [ "methods" => "GET", "callback" => [$this, "handle_get_models"], "permission_callback" => [$this, "check_permissions"], ]); // Refresh models endpoint. register_rest_route("wp-agentic-writer/v1", "/models/refresh", [ "methods" => "POST", "callback" => [$this, "handle_refresh_models"], "permission_callback" => [$this, "check_permissions"], ]); // Chat endpoint. register_rest_route("wp-agentic-writer/v1", "/chat", [ "methods" => "POST", "callback" => [$this, "handle_chat_request"], "permission_callback" => [$this, "check_permissions"], ]); // Clear chat context endpoint. register_rest_route("wp-agentic-writer/v1", "/clear-context", [ "methods" => "POST", "callback" => [$this, "handle_clear_context"], "permission_callback" => [$this, "check_permissions"], ]); // Chat history endpoint (deprecated - for backward compatibility only). register_rest_route( "wp-agentic-writer/v1", "/chat-history/(?P\d+)", [ "methods" => "GET", "callback" => [$this, "handle_get_chat_history"], "permission_callback" => [$this, "check_permissions"], ], ); // Conversation session endpoint (canonical for chat hydration). register_rest_route( "wp-agentic-writer/v1", "/conversation/(?P\d+)", [ "methods" => "GET", "callback" => [$this, "handle_get_conversation_by_post"], "permission_callback" => [$this, "check_permissions"], ], ); // Post config endpoints. register_rest_route( "wp-agentic-writer/v1", "/post-config/(?P\d+)", [ "methods" => "GET", "callback" => [$this, "handle_get_post_config"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route( "wp-agentic-writer/v1", "/post-config/(?P\d+)", [ "methods" => "POST", "callback" => [$this, "handle_update_post_config"], "permission_callback" => [$this, "check_permissions"], ], ); // Generate plan endpoint. register_rest_route("wp-agentic-writer/v1", "/generate-plan", [ "methods" => "POST", "callback" => [$this, "handle_generate_plan"], "permission_callback" => [$this, "check_permissions"], ]); // Revise plan endpoint. register_rest_route("wp-agentic-writer/v1", "/revise-plan", [ "methods" => "POST", "callback" => [$this, "handle_revise_plan"], "permission_callback" => [$this, "check_permissions"], ]); // Execute article endpoint. register_rest_route("wp-agentic-writer/v1", "/execute-article", [ "methods" => "POST", "callback" => [$this->controller_writing, "handle_execute_article"], "permission_callback" => [$this, "check_permissions"], ]); // Reformat blocks endpoint. register_rest_route("wp-agentic-writer/v1", "/reformat-blocks", [ "methods" => "POST", "callback" => [$this->controller_writing, "handle_reformat_blocks"], "permission_callback" => [$this, "check_permissions"], ]); // Regenerate block endpoint. register_rest_route("wp-agentic-writer/v1", "/regenerate-block", [ "methods" => "POST", "callback" => [ $this->controller_refinement, "handle_regenerate_block", ], "permission_callback" => [$this, "check_permissions"], ]); // Check clarity endpoint. register_rest_route("wp-agentic-writer/v1", "/check-clarity", [ "methods" => "POST", "callback" => [$this, "handle_check_clarity"], "permission_callback" => [$this, "check_permissions"], ]); // Block refine endpoint. register_rest_route("wp-agentic-writer/v1", "/refine-block", [ "methods" => "POST", "callback" => [$this->controller_refinement, "handle_block_refine"], "permission_callback" => [$this, "check_permissions"], ]); // Chat-based block refinement endpoint. register_rest_route("wp-agentic-writer/v1", "/refine-from-chat", [ "methods" => "POST", "callback" => [ $this->controller_refinement, "handle_refine_from_chat", ], "permission_callback" => [$this, "check_permissions"], ]); // Section block mapping endpoints. register_rest_route("wp-agentic-writer/v1", "/section-blocks", [ "methods" => "POST", "callback" => [ $this->controller_writing, "handle_save_section_blocks", ], "permission_callback" => [$this, "check_permissions"], ]); register_rest_route( "wp-agentic-writer/v1", "/section-blocks/(?P\d+)", [ "methods" => "GET", "callback" => [ $this->controller_writing, "handle_get_section_blocks", ], "permission_callback" => [$this, "check_permissions"], ], ); // Get cost tracking data endpoint. register_rest_route( "wp-agentic-writer/v1", "/cost-tracking/(?P\d+)", [ "methods" => "GET", "callback" => [$this, "handle_get_cost_tracking"], "permission_callback" => [$this, "check_permissions"], ], ); // SEO audit endpoint. register_rest_route( "wp-agentic-writer/v1", "/seo-audit/(?P\d+)", [ "methods" => "GET", "callback" => [$this->controller_seo, "handle_seo_audit"], "permission_callback" => [$this, "check_permissions"], ], ); // Generate meta description endpoint. register_rest_route("wp-agentic-writer/v1", "/generate-meta", [ "methods" => "POST", "callback" => [$this->controller_seo, "handle_generate_meta"], "permission_callback" => [$this, "check_permissions"], ]); // Suggest keywords endpoint. register_rest_route("wp-agentic-writer/v1", "/suggest-keywords", [ "methods" => "POST", "callback" => [$this->controller_seo, "handle_suggest_keywords"], "permission_callback" => [$this, "check_permissions"], ]); // Summarize context endpoint. register_rest_route("wp-agentic-writer/v1", "/summarize-context", [ "methods" => "POST", "callback" => [$this->controller_seo, "handle_summarize_context"], "permission_callback" => [$this, "check_permissions"], ]); // Multi-pass refinement endpoint. register_rest_route("wp-agentic-writer/v1", "/refine-multi-pass", [ "methods" => "POST", "callback" => [$this->controller_seo, "handle_refine_multi_pass"], "permission_callback" => [$this, "check_permissions"], ]); // Article-wide refinement endpoint. register_rest_route("wp-agentic-writer/v1", "/refine-article", [ "methods" => "POST", "callback" => [$this->controller_seo, "handle_refine_article"], "permission_callback" => [$this, "check_permissions"], ]); // GEO scoring endpoint. register_rest_route( "wp-agentic-writer/v1", "/geo-score/(?P\d+)", [ "methods" => "GET", "callback" => [$this->controller_seo, "handle_geo_score"], "permission_callback" => [$this, "check_permissions"], ], ); // Proactive suggestions endpoint (idle analysis) register_rest_route("wp-agentic-writer/v1", "/suggest-improvements", [ "methods" => "POST", "callback" => [ $this->controller_seo, "handle_suggest_improvements", ], "permission_callback" => [$this, "check_permissions"], ]); // Detect intent endpoint. register_rest_route("wp-agentic-writer/v1", "/detect-intent", [ "methods" => "POST", "callback" => [$this, "handle_detect_intent"], "permission_callback" => [$this, "check_permissions"], ]); // Image generation endpoints. register_rest_route( "wp-agentic-writer/v1", "/image-recommendations/(?P\d+)", [ "methods" => "GET", "callback" => [ $this->controller_image, "handle_get_image_recommendations", ], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route("wp-agentic-writer/v1", "/generate-image", [ "methods" => "POST", "callback" => [$this->controller_image, "handle_generate_image"], "permission_callback" => [$this, "check_permissions"], ]); register_rest_route("wp-agentic-writer/v1", "/commit-image", [ "methods" => "POST", "callback" => [$this->controller_image, "handle_commit_image"], "permission_callback" => [$this, "check_permissions"], ]); // Writing state persistence endpoint. register_rest_route( "wp-agentic-writer/v1", "/writing-state/(?P\d+)", [ "methods" => "GET", "callback" => [$this, "handle_get_writing_state"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route( "wp-agentic-writer/v1", "/writing-state/(?P\d+)", [ "methods" => "POST", "callback" => [$this, "handle_save_writing_state"], "permission_callback" => [$this, "check_permissions"], ], ); // Generate title endpoint (uses WP 7.0 AI Client when available). register_rest_route("wp-agentic-writer/v1", "/generate-title", [ "methods" => "POST", "callback" => [$this, "handle_generate_title"], "permission_callback" => [$this, "check_permissions"], ]); // Refine title endpoint (instruction-driven rewrite from chat mention @title). register_rest_route("wp-agentic-writer/v1", "/refine-title", [ "methods" => "POST", "callback" => [$this, "handle_refine_title"], "permission_callback" => [$this, "check_permissions"], ]); // Generate excerpt endpoint (uses WP 7.0 AI Client when available). register_rest_route("wp-agentic-writer/v1", "/generate-excerpt", [ "methods" => "POST", "callback" => [$this, "handle_generate_excerpt"], "permission_callback" => [$this, "check_permissions"], ]); // AI capabilities status endpoint. register_rest_route("wp-agentic-writer/v1", "/ai-capabilities", [ "methods" => "GET", "callback" => [$this, "handle_get_ai_capabilities"], "permission_callback" => [$this, "check_permissions"], ]); // Brave Search endpoint for research. register_rest_route("wp-agentic-writer/v1", "/search", [ "methods" => "POST", "callback" => [$this, "handle_search"], "permission_callback" => [$this, "check_permissions"], ]); // Fetch web content endpoint for research. register_rest_route("wp-agentic-writer/v1", "/fetch-content", [ "methods" => "POST", "callback" => [$this, "handle_fetch_content"], "permission_callback" => [$this, "check_permissions"], ]); // Research summary endpoint. register_rest_route("wp-agentic-writer/v1", "/research-summary", [ "methods" => "POST", "callback" => [$this, "handle_research_summary"], "permission_callback" => [$this, "check_permissions"], ]); // MEMANTO status endpoint. register_rest_route("wp-agentic-writer/v1", "/memanto/status", [ "methods" => "GET", "callback" => [$this, "handle_memanto_status"], "permission_callback" => [$this, "check_permissions"], ]); // MEMANTO recall endpoint — recent memories for a post. register_rest_route("wp-agentic-writer/v1", "/memanto/recall", [ "methods" => "GET", "callback" => [$this, "handle_memanto_recall"], "permission_callback" => [$this, "check_permissions"], ]); // MEMANTO session restore endpoint — for cross-session restore on editor load. register_rest_route("wp-agentic-writer/v1", "/memanto/restore", [ "methods" => "GET", "callback" => [$this, "handle_memanto_restore"], "permission_callback" => [$this, "check_permissions"], ]); // MEMANTO user preferences endpoint — for new post config carry-over. register_rest_route("wp-agentic-writer/v1", "/memanto/preferences", [ "methods" => "GET", "callback" => [$this, "handle_memanto_preferences"], "permission_callback" => [$this, "check_permissions"], ]); // Conversation sessions endpoints. register_rest_route("wp-agentic-writer/v1", "/conversations", [ "methods" => "GET", "callback" => [$this, "handle_get_conversations"], "permission_callback" => [$this, "check_permissions"], ]); register_rest_route( "wp-agentic-writer/v1", "/conversations/post/(?P\d+)", [ "methods" => "GET", "callback" => [$this, "handle_get_conversations"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route("wp-agentic-writer/v1", "/conversations", [ "methods" => "POST", "callback" => [$this, "handle_create_conversation"], "permission_callback" => [$this, "check_permissions"], ]); register_rest_route( "wp-agentic-writer/v1", "/conversations/(?P[a-zA-Z0-9]+)", [ "methods" => "GET", "callback" => [$this, "handle_get_conversation"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route( "wp-agentic-writer/v1", "/conversations/(?P[a-zA-Z0-9]+)", [ "methods" => "PUT", "callback" => [$this, "handle_update_conversation"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route( "wp-agentic-writer/v1", "/conversations/(?P[a-zA-Z0-9]+)", [ "methods" => "DELETE", "callback" => [$this, "handle_delete_conversation"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route( "wp-agentic-writer/v1", "/conversations/(?P[a-zA-Z0-9]+)/messages", [ "methods" => ["POST", "PUT"], "callback" => [$this, "handle_update_conversation_messages"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route( "wp-agentic-writer/v1", "/conversations/(?P[a-zA-Z0-9]+)/link-post", [ "methods" => "POST", "callback" => [$this, "handle_link_conversation_to_post"], "permission_callback" => [$this, "check_permissions"], ], ); // Session edit-lock endpoints (multi-tab safety). register_rest_route( "wp-agentic-writer/v1", "/conversations/(?P[a-zA-Z0-9]+)/lock", [ "methods" => "POST", "callback" => [$this, "handle_session_lock"], "permission_callback" => [$this, "check_permissions"], ], ); register_rest_route( "wp-agentic-writer/v1", "/conversations/(?P[a-zA-Z0-9]+)/lock", [ "methods" => "DELETE", "callback" => [$this, "handle_session_unlock"], "permission_callback" => [$this, "check_permissions"], ], ); // Legacy migration endpoint for converting post meta chat history to sessions register_rest_route( "wp-agentic-writer/v1", "/migrate-chat-history/(?P\d+)", [ "methods" => "POST", "callback" => [$this, "handle_migrate_chat_history"], "permission_callback" => [$this, "check_permissions"], ], ); // User preferences endpoints (per-user settings) register_rest_route("wp-agentic-writer/v1", "/user-preferences", [ "methods" => "GET", "callback" => [$this, "handle_get_user_preferences"], "permission_callback" => "__return_true", ]); register_rest_route("wp-agentic-writer/v1", "/user-preferences", [ "methods" => "POST", "callback" => [$this, "handle_save_user_preferences"], "permission_callback" => [$this, "check_permissions"], ]); } /** * Handle get writing state request. * * @since 0.2.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_get_writing_state($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"), ["status" => 400], ); } // Authorization: Check if user can edit this specific post. if (!current_user_can("edit_post", $post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } $state = [ "status" => get_post_meta($post_id, "_wpaw_writing_status", true) ?: "idle", "current_section_index" => (int) get_post_meta($post_id, "_wpaw_current_section", true) ?: 0, "sections_written" => get_post_meta($post_id, "_wpaw_sections_written", true) ?: [], "last_updated" => get_post_meta($post_id, "_wpaw_writing_state_updated", true) ?: null, "plan_id" => get_post_meta($post_id, "_wpaw_plan_id", true) ?: null, "resume_token" => get_post_meta($post_id, "_wpaw_resume_token", true) ?: null, ]; return new WP_REST_Response($state, 200); } /** * Handle save writing state request. * * @since 0.2.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_save_writing_state($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"), ["status" => 400], ); } // Authorization: Check if user can edit this specific post. if (!current_user_can("edit_post", $post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to modify this post.", "wp-agentic-writer", ), ["status" => 403], ); } $params = $request->get_json_params(); // Validate status against allowed values. $allowed_statuses = [ "idle", "in_progress", "paused", "completed", "failed", ]; $status = sanitize_text_field($params["status"] ?? "idle"); if (!in_array($status, $allowed_statuses, true)) { $status = "idle"; } // Save writing status update_post_meta($post_id, "_wpaw_writing_status", $status); // Save current section index $section_index = (int) ($params["current_section_index"] ?? 0); update_post_meta($post_id, "_wpaw_current_section", $section_index); // Save sections written array $sections_written = is_array($params["sections_written"] ?? null) ? array_map("sanitize_text_field", $params["sections_written"]) : []; update_post_meta($post_id, "_wpaw_sections_written", $sections_written); // Save plan ID $plan_id = sanitize_text_field($params["plan_id"] ?? ""); update_post_meta($post_id, "_wpaw_plan_id", $plan_id); // Save resume token $resume_token = sanitize_text_field($params["resume_token"] ?? ""); update_post_meta($post_id, "_wpaw_resume_token", $resume_token); // Update timestamp update_post_meta( $post_id, "_wpaw_writing_state_updated", current_time("mysql"), ); $state = [ "status" => $status, "current_section_index" => $section_index, "sections_written" => $sections_written, "last_updated" => current_time("mysql"), "plan_id" => $plan_id, ]; return new WP_REST_Response($state, 200); } /** * Check permissions. * * @since 0.1.0 * @return bool True if user has permission. */ public function check_permissions() { return current_user_can("edit_posts"); } /** * Check post-specific edit permissions. * * @since 0.1.3 * @param int $post_id Post ID to check. * @return bool True if user can edit the post. */ public function check_post_permission($post_id) { if ($post_id <= 0) { return false; } return current_user_can("edit_post", $post_id); } /** * Resolve session ID from request, or auto-create a post-linked session. * * @param string $session_id Existing session ID from request. * @param int $post_id Post ID. * @return string */ public function resolve_or_create_session_id($session_id, $post_id) { $session_id = sanitize_text_field((string) $session_id); if ("" !== $session_id) { return $session_id; } $post_id = (int) $post_id; $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); if ($post_id <= 0) { $created_unassigned = $manager->create_session([ "post_id" => 0, "title" => "Unassigned Session - " . current_time("Y-m-d H:i"), ]); return is_wp_error($created_unassigned) ? "" : (string) $created_unassigned; } $existing = $manager->get_session_by_post_id($post_id); if ($existing && !empty($existing["session_id"])) { return (string) $existing["session_id"]; } $created = $manager->create_session([ "post_id" => $post_id, "title" => "Post " . $post_id . " Session", ]); return is_wp_error($created) ? "" : (string) $created; } /** * Build provider metadata for responses. * * @since 0.1.4 * @param WPAW_Provider_Selection_Result $provider_result Provider selection result. * @param string $model Model identifier used. * @return array Provider metadata. */ public function build_provider_metadata($provider_result, $model = "") { $actual_provider = $provider_result->actual_provider ?? "unknown"; return [ "provider" => $actual_provider, "selected_provider" => $provider_result->selected_provider ?? $actual_provider, "fallback_used" => !empty($provider_result->fallback_used), "warnings" => $provider_result->warnings ?? [], "model" => $model, "byok_managed_by" => "openrouter" === $actual_provider ? "openrouter" : "", ]; } /** * Get a provider model label without assuming provider-specific helpers exist. * * @since 0.2.2 * @param object $provider Provider instance. * @param string $fallback Fallback model label. * @return string */ public function get_provider_execution_model( $provider, $fallback = "execution", ) { if ( is_object($provider) && method_exists($provider, "get_execution_model") ) { return (string) $provider->get_execution_model(); } return $fallback; } /** * Track AI cost with full metadata. * * This helper ensures all cost tracking includes provider, session, and status * metadata consistently. Use this instead of raw do_action calls. * * @since 0.2.0 * @param int $post_id Post ID. * @param string $model Model used. * @param string $action Action type (chat, planning, execution, etc). * @param int $input_tokens Input token count. * @param int $output_tokens Output token count. * @param float $cost Cost in USD. * @param mixed $provider_result Provider selection result or provider name string. * @param string $session_id Session ID (optional). * @param string $status Status (success, error) (optional, defaults to 'success'). */ public function track_ai_cost( $post_id, $model, $action, $input_tokens, $output_tokens, $cost, $provider_result, $session_id = "", $status = "success", ) { // Handle both provider result objects and plain strings if ( is_object($provider_result) && isset($provider_result->actual_provider) ) { $actual_provider = $provider_result->actual_provider; } elseif (is_string($provider_result)) { $actual_provider = $provider_result; } else { $actual_provider = "unknown"; } do_action( "wp_aw_after_api_request", $post_id, $model, $action, $input_tokens, $output_tokens, $cost, $actual_provider, $session_id, $status, ); } /** * 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"] ?? []; $post_id = $params["postId"] ?? 0; $type = $params["type"] ?? "planning"; $stream = !empty($params["stream"]); $session_id = $this->resolve_or_create_session_id( $params["sessionId"] ?? "", $post_id, ); // Check post permission if post_id is provided. if ($post_id > 0 && !$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } $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, ); // Extract focus keyword for context anchoring. $focus_keyword = ""; if (!empty($post_config["focus_keyword"])) { $focus_keyword = sanitize_text_field($post_config["focus_keyword"]); } elseif (!empty($post_config["seo_focus_keyword"])) { $focus_keyword = sanitize_text_field( $post_config["seo_focus_keyword"], ); } elseif ($post_id > 0) { $focus_keyword = get_post_meta( $post_id, "_wpaw_focus_keyword", true, ); } // Build focus keyword instruction for chat. $focus_keyword_instruction = ""; if (!empty($focus_keyword)) { $focus_keyword_instruction = " CONTEXT ANCHOR: The user is working on an article about \"{$focus_keyword}\". Keep your responses relevant to this primary topic. If the conversation drifts, gently guide it back to \"{$focus_keyword}\". At the END of your response, if you identify a good focus keyword from the discussion, suggest it in this format: **Focus Keyword Suggestion:** [your suggested keyword] "; } $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. {$focus_keyword_instruction} CRITICAL LANGUAGE REQUIREMENT: {$language_instruction} {$post_config_context}"; $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); $context_package = $context_builder->build_system_message( "chat", $session_id, $post_id, array_merge($params, [ "messages" => $messages, "postConfig" => $post_config, "latestUserMessage" => $last_user_message, ]), ); // OpenRouter is stateless; send only compact saved context plus the latest turn. $messages = []; if ("" !== trim((string) $last_user_message)) { $messages[] = [ "role" => "user", "content" => $last_user_message, ]; } $messages = $this->prepend_system_prompt($messages, $system_prompt); if (!empty($context_package["message"])) { array_splice($messages, 1, 0, [$context_package["message"]]); } // Get provider for this task type with selection metadata. $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type, ); $provider = $provider_result->provider; $provider_warnings = $provider_result->warnings; if ($stream) { $web_search_options = $this->get_web_search_options($post_config); $this->stream_chat_request( $messages, $post_id, $type, $web_search_options, $session_id, ); exit(); } // Send chat request. $response = $provider->chat($messages, [], $type); if (is_wp_error($response)) { return new WP_Error("chat_error", $response->get_error_message(), [ "status" => 500, ]); } // MEMANTO: Remember user message (fire-and-forget, never blocks). do_action( "wpaw_memanto_user_message", $session_id, $last_user_message, $post_id, ); // Track cost with provider and session metadata. $this->track_ai_cost( $post_id, $response["model"] ?? "", "chat", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $response["cost"] ?? 0, $provider_result, $session_id, "success", ); // Include provider metadata in response (DoD Provider Transparency contract). $response["provider"] = $provider_result->actual_provider; $response["selected_provider"] = $provider_result->selected_provider; $response["fallback_used"] = $provider_result->fallback_used; $response["warnings"] = $provider_warnings; $response["session_id"] = $session_id; $response["context_audit"] = $context_package["audit"] ?? []; // Also include nested form for consistency with other AI endpoints. $response["provider_metadata"] = $this->build_provider_metadata( $provider_result, $response["model"] ?? "", ); if (!empty($response["content"])) { // Storage: Persist to session table via Context Service only. // Legacy _wpaw_chat_history post meta is deprecated and no longer written. if (!empty($session_id)) { $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $context_service->add_message($session_id, [ "role" => "user", "content" => $last_user_message, "timestamp" => current_time("c"), ]); $context_service->add_message($session_id, [ "role" => "assistant", "content" => $response["content"], "timestamp" => current_time("c"), ]); } } // MEMANTO: Fire session start on first chat interaction. do_action( "wpaw_memanto_session_start", $session_id, $post_id, get_current_user_id(), ); 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. * @param string $session_id Session ID for context persistence. * @return void */ private function stream_chat_request( $messages, $post_id, $type, $web_search_options = [], $session_id = "", ) { header("Content-Type: text/event-stream"); header("Cache-Control: no-cache"); header("X-Accel-Buffering: no"); // Aggressively disable ALL output buffering layers (WordPress nests multiple). @ini_set("output_buffering", "Off"); @ini_set("zlib.output_compression", false); while (ob_get_level() > 0) { ob_end_flush(); } flush(); // Initialize streaming state variables. $accumulated_content = ""; $chunks_emitted = 0; $total_cost = 0; $last_user_message = $this->get_last_user_message($messages); // MEMANTO: Notify session start. do_action( "wpaw_memanto_session_start", $session_id, $post_id, get_current_user_id(), ); // Get provider with selection metadata for transparency. $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type, ); $provider = $provider_result->provider; $provider_warnings = $provider_result->warnings; echo "data: " . wp_json_encode([ "type" => "provider", "provider" => $provider_result->actual_provider, "selectedProvider" => $provider_result->selected_provider, "fallback_used" => $provider_result->fallback_used, "byok_managed_by" => "openrouter" === $provider_result->actual_provider ? "openrouter" : "", ]) . "\n\n"; flush(); $this->maybe_inject_brave_search( $messages, $provider, $web_search_options, ); $response = $provider->chat_stream( $messages, $web_search_options, $type, function ($chunk, $is_complete, $full_content) use ( &$accumulated_content, &$chunks_emitted, ) { $accumulated_content = $full_content; if ("" !== $chunk) { $chunks_emitted++; echo "data: " . wp_json_encode([ "type" => "conversational_stream", "content" => $accumulated_content, ]) . "\n\n"; if (ob_get_level() > 0) { ob_end_flush(); } flush(); } }, ); // Fallback: if streaming produced no chunks but we have accumulated content, emit it now. if ( 0 === $chunks_emitted && !is_wp_error($response) && !empty($response["content"]) ) { $accumulated_content = $response["content"]; echo "data: " . wp_json_encode([ "type" => "conversational_stream", "content" => $accumulated_content, ]) . "\n\n"; flush(); } if (is_wp_error($response)) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $response->get_error_message(), ]) . "\n\n"; flush(); exit(); } if ("" === trim((string) $accumulated_content)) { error_log( "WPAW Stream Debug: provider returned empty chat content", ); echo "data: " . wp_json_encode([ "type" => "error", "message" => __( "The provider returned an empty chat response.", "wp-agentic-writer", ), ]) . "\n\n"; flush(); exit(); } $total_cost = $response["cost"] ?? 0; // Debug: Log chat cost tracking (only when WP_DEBUG is on). wpaw_debug_log("Tracking chat cost", [ "post_id" => $post_id, "model" => $response["model"] ?? "unknown", "type" => $type, "cost" => $total_cost, ]); // Track cost with provider and session metadata. $this->track_ai_cost( $post_id, $response["model"] ?? "", "chat", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $total_cost, $provider_result, $session_id, "success", ); if (!empty($accumulated_content)) { echo "data: " . wp_json_encode([ "type" => "conversational", "content" => $accumulated_content, ]) . "\n\n"; flush(); // Storage: Persist to session table via Context Service only. // Legacy _wpaw_chat_history post meta is deprecated and no longer written. if (!empty($session_id)) { $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $context_service->add_message($session_id, [ "role" => "user", "content" => $last_user_message, "timestamp" => current_time("c"), ]); $context_service->add_message($session_id, [ "role" => "assistant", "content" => $accumulated_content, "timestamp" => current_time("c"), ]); } // MEMANTO: Remember user message from chat. do_action( "wpaw_memanto_user_message", $session_id, $last_user_message, $post_id, ); } // Send provider transparency metadata in completion event. echo "data: " . wp_json_encode([ "type" => "complete", "totalCost" => $total_cost, "session_id" => $session_id, "provider" => $provider_result->actual_provider, "fallback_used" => $provider_result->fallback_used, "warnings" => $provider_warnings, ]) . "\n\n"; flush(); } /** * Clear chat context for a post. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_clear_context($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php"; $ctrl = new WP_Agentic_Writer_Controller_Chat($this); return $ctrl->handle_clear_context($request); } /** * Get chat history for a post (deprecated compatibility endpoint). * * @since 0.1.0 * @deprecated 0.2.0 Use /wp-agentic-writer/v1/conversation/{post_id} instead. * This endpoint reads from conversation sessions via migration. * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_get_chat_history($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php"; $ctrl = new WP_Agentic_Writer_Controller_Chat($this); return $ctrl->handle_get_chat_history($request); } /** * Handle get conversation by post ID request (canonical endpoint). * * @since 0.2.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_get_conversation_by_post($request) { $post_id = intval($request["post_id"] ?? 0); if ($post_id <= 0) { return new WP_Error( "invalid_post", __("Invalid post ID.", "wp-agentic-writer"), ["status" => 400], ); } if (!$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); $session = $manager->get_session_by_post_id($post_id); if (!$session) { // Check for legacy post-meta chat history and migrate if present. $legacy_history = get_post_meta( $post_id, "_wpaw_chat_history", true, ); if (!empty($legacy_history) && is_array($legacy_history)) { $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id, ); // Fetch the newly created session after migration. $session = $manager->get_session_by_post_id($post_id); if ($session) { return new WP_REST_Response( [ "messages" => $session["messages"], "has_session" => true, "session_id" => $session["session_id"], "post_id" => $session["post_id"], "migrated" => true, "deprecated" => false, ], 200, ); } } return new WP_REST_Response( [ "messages" => [], "has_session" => false, ], 200, ); } return new WP_REST_Response( [ "messages" => $session["messages"], "has_session" => true, "session_id" => $session["session_id"], "post_id" => $session["post_id"], "deprecated" => false, ], 200, ); } /** * Update per-post chat history. * * @since 0.1.0 * @deprecated 0.1.4 Use conversation sessions instead. This method no longer writes * to post meta; it exists only for backward compatibility. * @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, ) { // Deprecated - now only used for migration reads. Do not write. // New code should use conversation sessions. return; } /** * Get per-post chat history. * * @since 0.1.0 * @deprecated 0.1.4 Use conversation sessions instead. * @param int $post_id Post ID. * @return array */ public function get_post_chat_history($post_id) { if ($post_id <= 0) { return []; } $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); $sessions = $manager->get_sessions_for_post($post_id); // If we have active sessions, return messages from the most recent one if (!empty($sessions)) { // Sort by last activity, most recent first usort($sessions, function ($a, $b) { return strtotime($b["last_activity"] ?? "") - strtotime($a["last_activity"] ?? ""); }); $active_session = $sessions[0]; $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $context = $context_service->get_context( $active_session["session_id"], $post_id, ); return $context["messages"] ?? []; } // No sessions found - check for legacy history and migrate $history = get_post_meta($post_id, "_wpaw_chat_history", true); if (!is_array($history) || empty($history)) { return []; } // Legacy data exists - trigger migration $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id, ); // Return migrated data using the returned session id if (!empty($migrated_session_id)) { $context = $context_service->get_context( $migrated_session_id, $post_id, ); return $context["messages"] ?? []; } return []; } /** * 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", []); return [ "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 "focus_keyword" => "", "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 */ public function sanitize_post_config($config) { $defaults = $this->get_default_post_config(); $config = is_array($config) ? $config : []; $sanitized = []; $allowed_lengths = ["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", []); $allowed_languages = array_merge( $settings["preferred_languages"] ?? [ "auto", "English", "Indonesian", ], $settings["custom_languages"] ?? [], ); // 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 = ["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["focus_keyword"] = sanitize_text_field( $config["focus_keyword"] ?? $defaults["focus_keyword"], ); if ( "" === $sanitized["focus_keyword"] && "" !== $sanitized["seo_focus_keyword"] ) { $sanitized["focus_keyword"] = $sanitized["seo_focus_keyword"]; } if ( "" === $sanitized["seo_focus_keyword"] && "" !== $sanitized["focus_keyword"] ) { $sanitized["seo_focus_keyword"] = $sanitized["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 context bundle (all meta in single query). * * @since 0.3.0 * @param int $post_id Post ID. * @return array Context bundle with memory, config, plan, etc. */ private function get_post_context_bundle($post_id) { if ($post_id <= 0) { return [ "memory" => [], "config" => $this->get_default_post_config(), "plan" => [], "plan_id" => "", "writing_state" => [], ]; } $all_meta = get_post_meta($post_id); $memory = isset($all_meta["_wpaw_memory"][0]) ? $all_meta["_wpaw_memory"][0] : []; if (!is_array($memory)) { $memory = []; } $defaults = $this->get_default_post_config(); $stored_config = isset($all_meta["_wpaw_post_config"][0]) ? $all_meta["_wpaw_post_config"][0] : []; if (!is_array($stored_config)) { $stored_config = []; } $config = $this->sanitize_post_config( wp_parse_args($stored_config, $defaults), ); $plan = isset($all_meta["_wpaw_plan"][0]) ? $all_meta["_wpaw_plan"][0] : []; if (!is_array($plan)) { $plan = []; } $plan_id = isset($all_meta["_wpaw_plan_id"][0]) ? $all_meta["_wpaw_plan_id"][0] : ""; $writing_state = isset($all_meta["_wpaw_writing_state_updated"][0]) ? $all_meta["_wpaw_writing_state_updated"][0] : []; if (!is_array($writing_state)) { $writing_state = []; } return [ "memory" => $memory, "config" => $config, "plan" => $plan, "plan_id" => $plan_id, "writing_state" => $writing_state, ]; } /** * Get post config (merged with defaults). * * @since 0.1.0 * @param int $post_id Post ID. * @return array */ public function get_post_config($post_id) { $bundle = $this->get_post_context_bundle($post_id); return $bundle["config"]; } /** * 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 */ public 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 */ public function build_post_config_context($post_config) { $lines = []; 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 = []; 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. */ public function detect_language_from_text($text) { $text = strtolower($text); // Indonesian indicators $indonesian_words = [ "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 = [ "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 = [ "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 */ public 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" !== strtolower($fallback)) { return strtolower($fallback); } // Default to 'auto' instead of 'english' to let AI detect from context return "auto"; } /** * Resolve the explicit language answer from the clarity/config quiz. * * @since 0.2.3 * @param array $answers Clarification answers. * @return string Language answer or empty string. */ public function resolve_language_from_clarification_answers($answers) { if (empty($answers) || !is_array($answers)) { return ""; } $language = ""; if (isset($answers["config_language"])) { $language = (string) $answers["config_language"]; } else { foreach ($answers as $answer) { if (!is_array($answer)) { continue; } if ( isset($answer["id"]) && "config_language" === $answer["id"] ) { $language = (string) ($answer["value"] ?? ($answer["answer"] ?? "")); break; } } } if ( "__custom__" === $language && !empty($answers["config_language_custom"]) ) { $language = (string) $answers["config_language_custom"]; } $language = sanitize_text_field($language); if ( "" === $language || "__skipped__" === $language || "auto" === strtolower($language) ) { return ""; } return $language; } /** * Build language instruction for prompts. * * @since 0.1.0 * @param string $language Language code. * @param string $context Context label. * @return string */ public 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 "CRITICAL: Detect the language from the conversation history and topic. Write ALL {$context} in the SAME language as the user's input. If the user wrote in Indonesian, write in Indonesian. If English, write in English. Match the user's language exactly."; } // 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 */ public function prepend_system_prompt($messages, $prompt) { if (empty($prompt)) { return $messages; } $messages = is_array($messages) ? $messages : []; array_unshift($messages, [ "role" => "system", "content" => $prompt, ]); return $messages; } /** * Physically scrapes the web and injects the results as a system prompt if applicable. * * @since 0.1.0 * @param array &$messages Chat messages (passed by reference). * @param object $provider AI Provider instance. * @param array $web_search_options Web search options. * @return void */ public function maybe_inject_brave_search( &$messages, $provider, $web_search_options, ) { if (empty($web_search_options["web_search_enabled"])) { return; } // Check if Brave API key is configured $settings = get_option("wp_agentic_writer_settings", []); $brave_api_key = $settings["brave_search_api_key"] ?? ""; // Determine search strategy: // 1. If Brave API key is set -> Use Brave (regardless of provider) // 2. If using OpenRouter without Brave key -> Let OpenRouter's online models handle it // 3. If using Local Backend without Brave key -> No search available if ( empty($brave_api_key) && $provider instanceof WP_Agentic_Writer_OpenRouter_Provider ) { // No Brave API key with OpenRouter - let the model's built-in search handle it // OpenRouter's online models (e.g., gemini-2.5-flash-online) have search tools built-in return; } if (empty($brave_api_key)) { // Local Backend or other providers without Brave API key return; } $last_query = ""; foreach (array_reverse($messages) as $msg) { if ("user" === $msg["role"]) { $last_query = (string) $msg["content"]; break; } } if (empty($last_query)) { return; } $search_api = WP_Agentic_Writer_Custom_Search_API::get_instance(); $results = $search_api->search($last_query, 3); if (!is_wp_error($results) && !empty($results)) { $context_markdown = $search_api->format_results_for_llm( $results, $last_query, ); $injection_message = [ "role" => "system", "content" => $context_markdown, ]; $injected = false; for ($i = count($messages) - 1; $i >= 0; $i--) { if ("user" === $messages[$i]["role"]) { array_splice($messages, $i, 0, [$injection_message]); $injected = true; break; } } if (!$injected) { array_unshift($messages, $injection_message); } } } /** * Build web search option overrides. * * @since 0.1.0 * @param array $post_config Post config. * @return array */ public function get_web_search_options($post_config) { $settings = get_option("wp_agentic_writer_settings", []); return [ "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 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Config instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_get_post_config($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-config.php"; $ctrl = new WP_Agentic_Writer_Controller_Config($this); return $ctrl->handle_get_post_config($request); } /** * Handle update post config request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Config instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_update_post_config($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-config.php"; $ctrl = new WP_Agentic_Writer_Controller_Config($this); return $ctrl->handle_update_post_config($request); } /** * Get the last user message from a message list. * * @since 0.1.0 * @param array $messages Message list. * @return string */ public 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. */ /** * Handle generate plan request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Planning instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_generate_plan($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-planning.php"; $ctrl = new WP_Agentic_Writer_Controller_Planning($this); return $ctrl->handle_generate_plan($request); } /** * Handle revise plan request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Planning instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_revise_plan($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-planning.php"; $ctrl = new WP_Agentic_Writer_Controller_Planning($this); return $ctrl->handle_revise_plan($request); } /** * 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 */ public function ensure_plan_sections_with_tasks($plan, $previous_plan = []) { if (empty($plan) || !is_array($plan)) { return $plan; } $sections = $plan["sections"] ?? []; if (!is_array($sections)) { $sections = []; } $previous_sections = []; if (is_array($previous_plan)) { $previous_sections = $previous_plan["sections"] ?? []; if (!is_array($previous_sections)) { $previous_sections = []; } } $previous_by_id = []; $previous_by_title = []; 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 = []; 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. */ public function stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length = "medium", $clarification_answers = [], $detected_language = "auto", $post_config = [], $chat_history = [], $session_id = "", ) { // 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_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "planning", ); $provider = $provider_result->provider; $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); $clarified_language = $this->resolve_language_from_clarification_answers( $clarification_answers, ); $effective_language = $this->resolve_language_preference( $post_config, $clarified_language ?: $detected_language, ); // Extract focus keyword for context anchoring $focus_keyword = ""; if (!empty($post_config["focus_keyword"])) { $focus_keyword = sanitize_text_field($post_config["focus_keyword"]); } elseif (!empty($post_config["seo_focus_keyword"])) { $focus_keyword = sanitize_text_field( $post_config["seo_focus_keyword"], ); } // Save focus keyword to post meta for persistence if ($post_id > 0 && !empty($focus_keyword)) { update_post_meta($post_id, "_wpaw_focus_keyword", $focus_keyword); } 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 echo "data: " . wp_json_encode([ "type" => "provider", "provider" => $provider_result->actual_provider, "selectedProvider" => $provider_result->selected_provider, "fallback_used" => $provider_result->fallback_used, "byok_managed_by" => "openrouter" === $provider_result->actual_provider ? "openrouter" : "", ]) . "\n\n"; flush(); // 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 = []; 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 = [ "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"; } $context_builder = WP_Agentic_Writer_Context_Builder::get_instance(); $context_package = $context_builder->build_for_task( "planning", $session_id, $post_id, [ "topic" => $topic, "context" => $context, "chatHistory" => $chat_history, "postConfig" => $post_config, "clarificationAnswers" => $clarification_answers, "detectedLanguage" => $detected_language, ], ); $chat_history_context = "\n\n" . $context_package["working_context"] . "\n\n" . $context_package["research_context"]; // Add section limits based on article length. $length_section_limits = [ "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)", ); // Build focus keyword anchor instruction $focus_keyword_instruction = ""; if (!empty($focus_keyword)) { $focus_keyword_instruction = " PRIMARY TOPIC ANCHOR: \"{$focus_keyword}\" CRITICAL: This article MUST be about \"{$focus_keyword}\". - The title MUST include or clearly relate to \"{$focus_keyword}\" - All sections MUST support this primary topic - Recent conversation refinements are meant to ENHANCE this topic, not REPLACE it - If user discussed sub-topics, treat them as ASPECTS of the primary topic \"{$focus_keyword}\" "; } $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. {$focus_keyword_instruction} CRITICAL LANGUAGE REQUIREMENT: {$plan_language_instruction} Keep JSON keys in English for parsing, but write every user-visible JSON value (title, headings, descriptions, labels) in the required article language. 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\" } ] } ] } Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text. 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 = [ [ "role" => "system", "content" => $system_prompt, ], [ "role" => "user", "content" => "Topic: {$topic}\n\nContext: {$context}{$chat_history_context}{$clarity_context}{$post_config_context}{$memory_context}", ], ]; // Log the request for debugging (only when WP_DEBUG is on) wpaw_debug_log( "Calling OpenRouter API for planning. Topic: " . substr($topic, 0, 100), ); wpaw_debug_log("Detected language: " . $detected_language); $this->maybe_inject_brave_search( $messages, $provider, $web_search_options, ); $response = $provider->chat( $messages, array_merge( [ "temperature" => 0.7, "max_tokens" => 2200, ], $web_search_options, ), "planning", ); wpaw_debug_log("OpenRouter API response received"); if (is_wp_error($response)) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $response->get_error_message(), ]) . "\n\n"; flush(); exit(); } $content = $response["content"]; wpaw_debug_log( "stream_generatePlan content length: " . strlen($content), ); // Handle empty response gracefully if (empty(trim((string) $content))) { $model_used = $response["model"] ?? "unknown"; $input_tokens = $response["input_tokens"] ?? 0; echo "data: " . wp_json_encode([ "type" => "error", "message" => sprintf( 'The AI model (%s) returned an empty response. This usually means the model couldn\'t process the request. Try: 1) Use a different planning model in Settings, 2) Simplify your topic, or 3) Try again. (Tokens sent: %d)', $model_used, $input_tokens, ), ]) . "\n\n"; flush(); exit(); } $plan_json = $this->extract_plan_from_response($content, $topic); if (null === $plan_json) { wpaw_debug_log( "extract_plan_from_response failed in streaming. Content preview: " . substr($content, 0, 500), ); $preview = $this->build_model_output_preview($content); echo "data: " . wp_json_encode([ "type" => "error", "message" => 'The AI responded but the outline couldn\'t be parsed as JSON. This sometimes happens when the model adds extra text. Trying again usually fixes this. Preview: ' . $preview, ]) . "\n\n"; flush(); exit(); } $plan_json = $this->ensure_plan_sections_with_tasks($plan_json); // MEMANTO: Remember plan was generated. do_action("wpaw_memanto_plan_generated", $post_id, $plan_json); // Persist planning exchange into session history. if (!empty($session_id)) { $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $context_service->update_session_context($session_id, [ "working_summary" => [ "text" => $this->build_memory_summary_from_plan( $plan_json, ), "updated_at" => current_time("c"), "source_message_count" => 0, ], ]); $context_service->add_message($session_id, [ "role" => "user", "content" => trim((string) $topic), "timestamp" => current_time("c"), ]); $context_service->add_message($session_id, [ "role" => "assistant", "type" => "plan", "plan" => $plan_json, "content" => $this->build_plan_summary_for_session( $plan_json, $post_config, ), "timestamp" => current_time("c"), ]); } // 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, [ "summary" => $summary, "last_prompt" => $topic, "last_intent" => "generate", ]); } $total_cost += $response["cost"]; // Track plan cost. $this->track_ai_cost( $post_id, $response["model"], "planning", $response["input_tokens"], $response["output_tokens"], $response["cost"], $provider_result, $session_id, "success", ); // Send plan data. echo "data: " . wp_json_encode([ "type" => "plan", "plan" => $plan_json, "cost" => $response["cost"], "web_search_results" => $response["web_search_results"] ?? [], "context_audit" => $context_package["audit"] ?? [], ]) . "\n\n"; flush(); if (!empty($session_id)) { $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $context_service->append_session_context_item( $session_id, "plan_versions", [ "instruction" => sanitize_text_field($topic), "plan" => $plan_json, ], 10, ); $context_service->update_session_context($session_id, [ "working_summary" => [ "text" => $this->build_memory_summary_from_plan( $plan_json, ), "updated_at" => current_time("c"), "source_message_count" => 0, ], ]); } // 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_merge( [ "type" => "complete", "totalCost" => $total_cost, "session_id" => $session_id, ], $this->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ), ) . "\n\n"; flush(); } // Step 2: Auto-execute if requested. if ($auto_execute && !empty($plan_json["sections"])) { // Define length constraints with section counts $length_constraints = [ "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 = [ "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( [ "ID" => $post_id, "post_title" => sanitize_text_field( $plan_json["title"], ), ], true, // Return WP_Error on failure ); if (is_wp_error($update_result)) { wpaw_debug_log( "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([ "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 = []; 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([ "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 = [ [ "role" => "system", "content" => $system_prompt, ], [ "role" => "user", "content" => $section_prompt, ], ]; // Log before calling streaming API wpaw_debug_log("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([ "type" => "block", "sectionId" => $section_id, "block" => [ "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 wpaw_debug_log("Calling OpenRouter streaming API"); $response = $provider->chat_stream( $messages, ["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([ "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([ "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([ "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([ "type" => "markdown_stream", "content" => $markdown_content, ]) . "\n\n"; flush(); } } }, ); if (is_wp_error($response)) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $response->get_error_message(), ]) . "\n\n"; flush(); exit(); } // Handle empty response from model if (empty(trim((string) $accumulated_content))) { $model_used = $response["model"] ?? "unknown"; wpaw_debug_log( "Section writing got empty response from model: {$model_used}", ); echo "data: " . wp_json_encode([ "type" => "error", "message" => sprintf( 'Section "%s" got an empty response from the AI model (%s). Please retry.', $heading, $model_used, ), ]) . "\n\n"; flush(); exit(); } // If divider was never found, treat the entire content as markdown if (!$divider_found) { wpaw_debug_log( "No ~~~ARTICLE~~~ divider found in section response. Using full content as markdown.", ); $markdown_content = $accumulated_content; // Strip any leading conversational fluff (first line if it looks like a note) $lines = explode("\n", $markdown_content); if ( !empty($lines[0]) && !preg_match("/^#{1,3}\s/", $lines[0]) && strlen($lines[0]) < 200 ) { // First line might be a brief conversational note, skip it $first_line = array_shift($lines); if (!empty($first_line)) { echo "data: " . wp_json_encode([ "type" => "conversational", "content" => trim($first_line), ]) . "\n\n"; flush(); } $markdown_content = implode("\n", $lines); } } $section_cost = $response["cost"] ?? 0; $total_cost += $section_cost; // Debug: Log execution cost tracking (only when WP_DEBUG is on) wpaw_debug_log("Tracking execution cost", [ "post_id" => $post_id, "model" => $response["model"] ?? "unknown", "cost" => $section_cost, ]); // Track execution cost for this section. $this->track_ai_cost( $post_id, $response["model"] ?? "", "execution", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $section_cost, $provider_result, $session_id ?? "", "success", ); // NOW parse the complete markdown content and send blocks if (!empty($markdown_content)) { // Extract image placeholders and generate IDs $image_placeholders = []; if ( preg_match_all( "/\[IMAGE:\s*(.+?)\]/i", $markdown_content, $matches, ) ) { $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); foreach ($matches[1] as $index => $description) { $agent_image_id = "img_" . $post_id . "_" . time() . "_" . ($index + 1); $image_placeholders[] = [ "agent_image_id" => $agent_image_id, "description" => trim($description), ]; // Save to database $image_manager->save_image_recommendation( $post_id, $agent_image_id, "section_" . $section_id, $heading, trim($description), trim($description), ); } } $markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $markdown_content, $image_placeholders, ); foreach ($markdown_blocks as $block) { echo "data: " . wp_json_encode([ "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([ "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([ "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_merge( [ "type" => "complete", "totalCost" => $total_cost, "session_id" => $session_id, ], $this->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ), ) . "\n\n"; flush(); } catch (Exception $e) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $e->getMessage(), ]) . "\n\n"; flush(); } exit(); } /** * Build a compact, persistent outline summary for session history. * * @since 0.2.2 * @param array $plan_json Plan data. * @param array $post_config Post config. * @return string */ public function build_plan_summary_for_session( $plan_json, $post_config = [], ) { $title = trim((string) ($plan_json["title"] ?? "Outline ready")); $sections = is_array($plan_json["sections"] ?? null) ? $plan_json["sections"] : []; $lines = []; $lines[] = "Outline ready."; $lines[] = ""; $lines[] = $title; $lines[] = ""; $focus = trim((string) ($post_config["seo_focus_keyword"] ?? "")); $secondary = trim( (string) ($post_config["seo_secondary_keywords"] ?? ""), ); if ("" !== $focus || "" !== $secondary) { $lines[] = "SEO Snapshot:"; if ("" !== $focus) { $lines[] = "- Focus: " . $focus; } if ("" !== $secondary) { $lines[] = "- Secondary: " . $secondary; } $lines[] = ""; } $lines[] = "Sections:"; $index = 1; foreach ($sections as $section) { $heading = trim( (string) ($section["heading"] ?? ($section["title"] ?? "")), ); if ("" === $heading) { continue; } $lines[] = $index . ". " . $heading; $index++; } return implode("\n", $lines); } /** * Handle execute article request. * * @since 0.1.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ /** * Handle execute article request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Writing::handle_execute_article() instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_execute_article($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-writing.php"; $controller = new WP_Agentic_Writer_Controller_Writing($this); return $controller->handle_execute_article($request); } /** * Stream article execution from a stored plan. * * @since 0.1.0 * @param array $plan Article plan. * @param int $post_id Post ID. * @param array $post_config Post configuration. * @param string $effective_language Effective language. * @param string $session_id Session ID. * @return void Streams response to client. */ public function stream_execute_article( $plan, $post_id, $post_config, $effective_language, $session_id, ) { // Set headers for streaming. header("Content-Type: text/event-stream"); header("Cache-Control: no-cache"); header("X-Accel-Buffering: no"); // Flush output buffer. if (ob_get_level() > 0) { ob_end_clean(); } flush(); // Get provider for writing task. $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "writing", ); $provider = $provider_result->provider; $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", ); // Build image instruction. $image_instruction = "IMAGE SUGGESTIONS: - Suggest where images would enhance understanding - Place image suggestions on their own line: [IMAGE: descriptive alt text] - Maximum 1 image per section"; if (empty($post_config["include_images"])) { $image_instruction = "IMAGE SUGGESTIONS: - Do NOT include any image suggestions."; } // Build SEO instructions if enabled. $seo_instruction = ""; if ( !empty($post_config["seo_enabled"]) && !empty($post_config["seo_focus_keyword"]) ) { $focus_keyword = $post_config["seo_focus_keyword"]; $seo_instruction = "\n\nSEO REQUIREMENTS: - Focus Keyword: \"{$focus_keyword}\" - Include focus keyword in title, first paragraph, and 2-3 headings."; } // Build system prompt. $system_prompt = "You are an industry practitioner sharing insights with a colleague. ANTI-ROBOT RULES: - BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament. - BURSTINESS: Mix short, punchy sentences with longer ones. - TONE: Conversational, direct, pragmatic. CRITICAL LANGUAGE REQUIREMENT: {$language_instruction} {$post_config_context} Write engaging, high-information-density content. {$seo_instruction} {$image_instruction}"; $plan = $this->ensure_plan_sections_with_tasks($plan); // Update post title if available. if (!empty($plan["title"]) && $post_id > 0) { $post = get_post($post_id); if ( $post && empty($post->post_title) && current_user_can("edit_post", $post_id) ) { wp_update_post([ "ID" => $post_id, "post_title" => sanitize_text_field($plan["title"]), ]); } } // MEMANTO: Plan execution implies approval. do_action("wpaw_memanto_plan_approved", $post_id, $plan); // Build sections to write. $sections_to_write = []; foreach ($plan["sections"] as $index => $section) { if (($section["status"] ?? "pending") !== "done") { $sections_to_write[$index] = $section; } } $total_cost = 0; $all_blocks = []; $section_count = count($sections_to_write); $current_section = 0; foreach ($sections_to_write as $section) { $current_section++; $heading = $section["heading"] ?? ($section["title"] ?? ""); $this->send_status( "writing_section", "Writing: " . ($heading ?: "Section " . $current_section), ); if ($heading) { $all_blocks[] = [ "type" => "heading", "content" => $heading, "level" => 2, ]; } $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 = [ ["role" => "system", "content" => $system_prompt], ["role" => "user", "content" => $section_prompt], ]; $response = $provider->chat( $messages, ["temperature" => 0.8], "execution", ); if (is_wp_error($response)) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $response->get_error_message(), ]) . "\n\n"; flush(); exit(); } $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) { $all_blocks[] = $block; } } else { $all_blocks[] = [ "type" => "paragraph", "content" => $response["content"], ]; } $total_cost += $response["cost"]; // Send progress update. echo "data: " . wp_json_encode([ "type" => "progress", "current" => $current_section, "total" => $section_count, "heading" => $heading, ]) . "\n\n"; flush(); // MEMANTO: Remember section written. $section_id = $section["id"] ?? sanitize_title($heading); do_action( "wpaw_memanto_section_written", $post_id, $section_id, $heading, ); } // Mark sections as done. 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 cost. $this->track_ai_cost( $post_id, $this->get_provider_execution_model($provider, "execution"), "execution", 0, 0, $total_cost, $provider_result, "", "success", ); // Send completion. echo "data: " . wp_json_encode([ "type" => "complete", "blocks" => $all_blocks, "cost" => $total_cost, "recommended_title" => $plan["title"] ?? "", "provider_metadata" => $this->build_provider_metadata( $provider_result, $this->get_provider_execution_model($provider, "execution"), ), ]) . "\n\n"; flush(); exit(); } /** * Handle reformat blocks request. * * @since 0.1.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ /** * Handle reformat blocks request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Writing::handle_reformat_blocks() instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_reformat_blocks($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-writing.php"; $controller = new WP_Agentic_Writer_Controller_Writing($this); return $controller->handle_reformat_blocks($request); } /** * Handle regenerate block request. * * @since 0.1.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ /** * Handle regenerate block request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Refinement::handle_regenerate_block() instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_regenerate_block($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-refinement.php"; $controller = new WP_Agentic_Writer_Controller_Refinement($this); return $controller->handle_regenerate_block($request); } /** * Handle get cost tracking request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Cost instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_get_cost_tracking($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-cost.php"; $controller = new WP_Agentic_Writer_Controller_Cost($this); return $controller->handle_get_cost_tracking($request); } /** * Extract JSON from string. * * @since 0.1.0 * @param string $string String containing JSON. * @return array|null Decoded JSON or null if invalid. */ /** * Extract JSON from model response text. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_json() instead. * @param string $string Raw model response. * @return array|null */ public function extract_json($string) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::extract_json($string); } /** * Extract balanced JSON object candidates from model text. * * @since 0.2.2 * @param string $string Source text. * @return array */ /** * Extract balanced JSON object candidates. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_balanced_json_candidates() instead. * @param string $string Source text. * @param string $open_char Opening character. * @param string $close_char Closing character. * @return array */ private function extract_balanced_json_candidates( $string, $open_char = "{", $close_char = "}", ) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::extract_balanced_json_candidates( $string, $open_char, $close_char, ); } /** * Extract an article plan from model output, falling back to markdown outlines. * * @since 0.2.2 * @param string $content Model response. * @param string $fallback_title Fallback title/topic. * @param array $previous_plan Previous plan for revisions. * @return array|null */ /** * Extract plan from model response. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response() instead. * @param string $content Raw model response. * @param string $fallback_title Fallback title. * @param array $previous_plan Previous plan. * @return array|null */ private function extract_plan_from_response( $content, $fallback_title = "", $previous_plan = [], ) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response( $content, $fallback_title, $previous_plan, ); } /** * Build a short, safe preview of unparseable model output. * * @since 0.2.3 * @param string $content Model response. * @return string */ public function build_model_output_preview($content) { $preview = trim(wp_strip_all_tags((string) $content)); $preview = preg_replace("/\s+/", " ", $preview); if (function_exists("mb_substr")) { $preview = mb_substr($preview, 0, 240); } else { $preview = substr($preview, 0, 240); } return "" !== $preview ? $preview : "(empty response)"; } /** * Normalize common model outline JSON variants into the required plan schema. * * @since 0.2.3 * @param mixed $json Decoded model JSON. * @param string $fallback_title Fallback title/topic. * @return array|null */ /** * Normalize extracted plan JSON. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::normalize_extracted_plan_json() instead. * @param mixed $json Decoded JSON. * @param string $fallback_title Fallback title. * @return array|null */ private function normalize_extracted_plan_json($json, $fallback_title = "") { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::normalize_extracted_plan_json( $json, $fallback_title, ); } /** * Normalize varied model section content into plan content items. * * @since 0.2.3 * @param mixed $items Section content candidate. * @return array */ /** * Normalize plan section content items. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::normalize_plan_section_content_items() instead. * @param mixed $items Section content. * @return array */ private function normalize_plan_section_content_items($items) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::normalize_plan_section_content_items( $items, ); } /** * Build a plan schema from markdown/numbered outline output. * * @since 0.2.2 * @param string $content Model response. * @param string $fallback_title Fallback title/topic. * @param array $previous_plan Previous plan for revisions. * @return array|null */ /** * Build plan from markdown outline. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::build_plan_from_markdown_outline() instead. * @param string $content Markdown content. * @param string $fallback_title Fallback title. * @param array $previous_plan Previous plan. * @return array|null */ private function build_plan_from_markdown_outline( $content, $fallback_title = "", $previous_plan = [], ) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::build_plan_from_markdown_outline( $content, $fallback_title, $previous_plan, ); } /** * Clean markdown decoration from an outline heading. * * @since 0.2.2 * @param string $heading Heading text. * @return string */ /** * Clean outline heading. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::clean_outline_heading() instead. * @param string $heading Heading text. * @return string */ private function clean_outline_heading($heading) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::clean_outline_heading( $heading, ); } /** * Handle get models request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Models instead. * @return WP_REST_Response|WP_Error Response. */ public function handle_get_models() { require_once WPAW_PLUGIN_DIR . "includes/class-controller-models.php"; $controller = new WP_Agentic_Writer_Controller_Models($this); return $controller->handle_get_models(); } /** * Handle refresh models request. * * @since 0.1.0 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Models instead. * @return WP_REST_Response|WP_Error Response. */ public function handle_refresh_models() { require_once WPAW_PLUGIN_DIR . "includes/class-controller-models.php"; $controller = new WP_Agentic_Writer_Controller_Models($this); return $controller->handle_refresh_models(); } /** * Handle check clarity request. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_check_clarity($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php"; $controller = new WP_Agentic_Writer_Controller_Clarity($this); return $controller->handle_check_clarity($request); } /** * Append configuration questions to clarity quiz. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead. * @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) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php"; $controller = new WP_Agentic_Writer_Controller_Clarity($this); return $controller->append_config_questions($questions, $post_config); } /** * 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"] ?? []; $post_id = $params["postId"] ?? 0; $stream = $params["stream"] ?? false; $chat_history = $params["chatHistory"] ?? []; if (empty($block_content) || empty($refinement_request)) { return new WP_Error( "missing_data", __( "Block content and refinement request are required.", "wp-agentic-writer", ), ["status" => 400], ); } // Check post permission BEFORE reading post data. if ($post_id > 0 && !$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } // Only read post config after permission check. $post_config = $this->resolve_post_config_from_request( $params, $post_id, ); // 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_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "refinement", ); $provider = $provider_result->provider; // 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 = [ [ "role" => "system", "content" => $system_prompt, ], [ "role" => "user", "content" => "Please refine this content.", ], ]; $response = $provider->chat( $messages, ["temperature" => 0.7], "execution", ); if (is_wp_error($response)) { return new WP_Error( "refinement_error", $response->get_error_message(), ["status" => 500], ); } // Parse refined content as Gutenberg blocks. $blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response["content"], ); // MEMANTO: Remember block refinement. do_action( "wpaw_memanto_block_refined", $post_id, $block_id, $refinement_request, ); // Track cost (always track for debugging). $this->track_ai_cost( $post_id, $response["model"] ?? "", "block_refinement", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $response["cost"] ?? 0, $provider_result, $session_id, "success", ); return new WP_REST_Response( [ "blocks" => $blocks, "blockId" => $block_id, "cost" => $response["cost"] ?? 0, "provider_metadata" => $this->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ], 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. */ public function stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config = [], ) { // 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_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "refinement", ); $provider = $provider_result->provider; $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 a precise content editor. Your task is to refine the provided content based strictly on the user's request. ANTI-ROBOT RULES: - BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament. - Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested. - Increase specificity and information density. Do not just increase word count with conversational filler. 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 = [ [ "role" => "system", "content" => $system_prompt, ], [ "role" => "user", "content" => "Refine this content.", ], ]; $response = $provider->chat( $messages, ["temperature" => 0.7], "execution", ); if (is_wp_error($response)) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $response->get_error_message(), ]) . "\n\n"; flush(); exit(); } // Track cost (always track for debugging). $this->track_ai_cost( $post_id, $response["model"] ?? "", "block_refinement", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $response["cost"] ?? 0, $provider_result, $session_id ?? "", "success", ); $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 = []; $block_name = "core/paragraph"; // Default if ($resolved_block_type === "core/paragraph") { $block_name = "core/paragraph"; $block_attrs = ["content" => $refined_content]; // Create proper HTML for paragraph $block_html = "

" . $refined_content . "

"; // Create proper block structure $block_data = [ "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 = [ "level" => $level, "content" => $refined_content, ]; $tag = "h" . $level; $block_html = "<{$tag}>{$refined_content}"; $block_data = [ "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 = []; foreach ($lines as $line) { $inner_blocks[] = [ "blockName" => "core/list-item", "attrs" => ["content" => $line], "innerHTML" => "
  • " . $line . "
  • ", ]; } $block_attrs = ["ordered" => false]; $block_html = "
      " . implode( "", array_map(function ($item) { return $item["innerHTML"]; }, $inner_blocks), ) . "
    "; $block_data = [ "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 = ["content" => $refined_content]; $block_html = "

    " . $refined_content . "

    "; $block_data = [ "blockName" => $block_name, "attrs" => $block_attrs, "innerHTML" => $block_html, "clientId" => $block_id, ]; } // Send the refined block echo "data: " . wp_json_encode([ "type" => "block", "block" => $block_data, ]) . "\n\n"; flush(); // Small delay for visual effect usleep(100000); // Send completion message with provider metadata. echo "data: " . wp_json_encode([ "type" => "complete", "blockId" => $block_id, "totalCost" => $response["cost"], "provider_metadata" => $this->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ]) . "\n\n"; flush(); // MEMANTO: Remember block was refined. do_action( "wpaw_memanto_block_refined", $post_id, $block_id, $refinement_request, ); } catch (Exception $e) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $e->getMessage(), ]) . "\n\n"; flush(); } exit(); } /** * Check clarity before article generation. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead. * @param string $topic User topic. * @param array $answers Previous answers. * @param mixed $provider OpenRouter provider. * @return array Clarity check result with is_clear and questions. */ private function check_clarity_before_generation( $topic, $answers, $provider, ) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php"; $controller = new WP_Agentic_Writer_Controller_Clarity($this); return $controller->check_clarity_before_generation( $topic, $answers, $provider, ); } /** * 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 = [ "starting" => "", "planning" => "", "plan_complete" => "", "writing" => "", "writing_section" => "", "complete" => "", ]; $icon = isset($status_icons[$status]) ? $status_icons[$status] : ""; echo "data: " . wp_json_encode([ "type" => "status", "status" => $status, "message" => $message, "icon" => $icon, ]) . "\n\n"; flush(); } /** * Get default clarification questions when AI fails. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead. * @param string $topic User's topic. * @return array Clarification result with default questions. */ private function get_default_clarification_questions($topic) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php"; $controller = new WP_Agentic_Writer_Controller_Clarity($this); return $controller->get_default_clarification_questions($topic); } /** * 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; $session_id = $this->resolve_or_create_session_id( $params["sessionId"] ?? "", $post_id, ); $blocks_to_refine = $params["blocksToRefine"] ?? []; $all_blocks = $params["allBlocks"] ?? []; $diff_plan = !empty($params["diffPlan"]); $selective_refine = !empty($params["selectiveRefine"]); $audit_context = isset($params["auditContext"]) && is_array($params["auditContext"]) ? $params["auditContext"] : []; 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", ), ["status" => 400], ); } // Check post permission BEFORE reading post data. if ($post_id > 0 && !$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } // Only read post config after permission check. $post_config = $this->resolve_post_config_from_request( $params, $post_id, ); // 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, $session_id, $selective_refine, $audit_context, ); // 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"] ?? []; 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", ), ["status" => 400], ); } if (!$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } $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 = []; } $mapping[$section_id] = $block_ids; update_post_meta($post_id, "_wpaw_section_blocks", $mapping); return new WP_REST_Response( [ "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"), ["status" => 400], ); } if (!$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } $mapping = get_post_meta($post_id, "_wpaw_section_blocks", true); if (!is_array($mapping)) { $mapping = []; } return new WP_REST_Response( [ "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. */ public function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = [], $session_id = "", $selective_refine = false, $audit_context = [], ) { // 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, [ "last_prompt" => $message, "last_intent" => "refine", ]); } $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "writing", ); $provider = $provider_result->provider; $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; $failed_count = 0; $consecutive_errors = 0; $max_consecutive_errors = 3; $aborted_due_to_provider_errors = false; $last_model_used = ""; $total_blocks_to_refine = is_array($blocks_to_refine) ? count($blocks_to_refine) : 0; $batch_size = 5; $batch_total = $total_blocks_to_refine > 0 ? (int) ceil($total_blocks_to_refine / $batch_size) : 0; // Get post title for context $post = get_post($post_id); $post_title = $post ? $post->post_title : "Unknown"; // Normalize blocks for context $context_blocks = []; $block_source = is_array($all_blocks) && !empty($all_blocks) ? $all_blocks : $this->select_blocks(); $allowed_block_ids = array_values( array_filter( array_map( static function ($block_obj) { return sanitize_text_field( $block_obj["clientId"] ?? "", ); }, is_array($blocks_to_refine) ? $blocks_to_refine : [], ), ), ); 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"] ?? []); $content = $this->extract_block_content_from_attrs( $block_type, $block_attrs, ); if (empty($client_id)) { continue; } $context_blocks[] = [ "clientId" => $client_id, "type" => $block_type, "content" => $content, ]; } // Optional evaluator pass: classify blocks first, then refine only necessary ones. if ($selective_refine && count($blocks_to_refine) > 1) { echo "data: " . wp_json_encode([ "type" => "status", "message" => sprintf( "Evaluating %d block(s) to select only necessary refinements...", count($blocks_to_refine), ), ]) . "\n\n"; flush(); $eval_map = []; foreach ($blocks_to_refine as $block_obj) { $cid = sanitize_text_field($block_obj["clientId"] ?? ""); $bname = sanitize_text_field( $block_obj["name"] ?? "core/paragraph", ); $battrs = $block_obj["attributes"] ?? []; $txt = trim( wp_strip_all_tags( $this->extract_block_content_from_attrs( $bname, $battrs, ), ), ); if ("" === $cid || "" === $txt) { continue; } if (strlen($txt) > 220) { $txt = substr($txt, 0, 220) . "..."; } $eval_map[] = [ "blockId" => $cid, "type" => $bname, "text" => $txt, ]; } if (!empty($eval_map)) { $eval_prompt = "You are a strict editor classifier.\n" . "Task: decide which blocks NEED refinement for this user request.\n" . "Return ONLY JSON: {\"keep\":[\"id\"],\"needs_refine\":[\"id\"],\"reasons\":{\"id\":\"short reason\"}}\n" . "Rules: If block already satisfies request, keep it. Do not rewrite. Only classify.\n" . "User request: {$message}\nBlocks:\n"; foreach ($eval_map as $row) { $eval_prompt .= "- {$row["blockId"]} | {$row["type"]} | {$row["text"]}\n"; } $eval_response = $provider->chat( [ ["role" => "system", "content" => $eval_prompt], ["role" => "user", "content" => "Classify now."], ], ["temperature" => 0.1], "planning", ); if (!is_wp_error($eval_response)) { $eval_raw = trim( (string) ($eval_response["content"] ?? ""), ); if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $eval_raw, $m, ) ) { $eval_raw = trim($m[1]); } $eval_json = json_decode($eval_raw, true); if ( is_array($eval_json) && isset($eval_json["needs_refine"]) && is_array($eval_json["needs_refine"]) ) { $needs_lookup = array_fill_keys( array_map( "sanitize_text_field", $eval_json["needs_refine"], ), true, ); $filtered = array_values( array_filter( $blocks_to_refine, static function ($block_obj) use ( $needs_lookup, ) { $cid = sanitize_text_field( $block_obj["clientId"] ?? "", ); return isset($needs_lookup[$cid]); }, ), ); if (!empty($filtered)) { $before_count = count($blocks_to_refine); $blocks_to_refine = $filtered; echo "data: " . wp_json_encode([ "type" => "status", "message" => sprintf( 'Selective refinement: %1$d/%2$d block(s) need updates.', count($blocks_to_refine), $before_count, ), ]) . "\n\n"; flush(); } } } } } if ($diff_plan && !empty($context_blocks)) { $plan_generation_failed = false; $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} Allowed target block IDs (STRICT): " . implode(", ", $allowed_block_ids) . " Blocks: "; foreach ($context_blocks as $index => $block) { $plan_prompt .= $index + 1 . ". {$block["clientId"]} | {$block["type"]} | " . $block["content"] . "\n"; } $plan_response = $provider->chat( [ ["role" => "system", "content" => $plan_prompt], [ "role" => "user", "content" => "Create the edit plan now.", ], ], ["temperature" => 0.2], "planning", ); if (!is_wp_error($plan_response)) { // Track cost for edit plan generation $plan_cost = $plan_response["cost"] ?? 0; $this->track_ai_cost( $post_id, $plan_response["model"] ?? "", "refinement_planning", $plan_response["input_tokens"] ?? 0, $plan_response["output_tokens"] ?? 0, $plan_cost, $provider_result, $session_id ?? "", "success", ); $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"])) { $plan_json = $this->sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks, ); if (empty($plan_json["actions"])) { $plan_generation_failed = true; } else { echo "data: " . wp_json_encode([ "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), ); $plan_generation_failed = true; } } else { error_log( "WP Agentic Writer: Edit plan API error: " . $plan_response->get_error_message(), ); $plan_generation_failed = true; } // Fallback path: when edit-plan fails (common on broad @all requests), // continue with direct per-block refinement instead of hard failing. if ($plan_generation_failed) { echo "data: " . wp_json_encode([ "type" => "status", "message" => "Edit plan failed, switching to direct block refinement.", ]) . "\n\n"; flush(); } } foreach ($blocks_to_refine as $block_index_loop => $block_obj) { if (0 === $block_index_loop % $batch_size) { $current_batch = (int) floor($block_index_loop / $batch_size) + 1; $batch_start = $block_index_loop + 1; $batch_end = min( $total_blocks_to_refine, $block_index_loop + $batch_size, ); echo "data: " . wp_json_encode([ "type" => "status", "message" => sprintf( 'Processing batch %1$d/%2$d (blocks %3$d-%4$d of %5$d)', $current_batch, $batch_total, $batch_start, $batch_end, $total_blocks_to_refine, ), ]) . "\n\n"; flush(); } // 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"] ?? []; $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 = [ "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"; $section_context = $this->build_section_context_for_block( $all_blocks, $block_index, 4, ); if (!empty($section_context)) { $context_str .= "Section context:\n" . $section_context . "\n"; } if (!empty($article_context["nextBlock"])) { $context_str .= "Next section: " . $article_context["nextBlock"]["heading"] . "\n"; } $system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request. ANTI-ROBOT RULES: - BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament. - Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested. - Increase specificity and information density. Do not just increase word count with conversational filler. 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 payload, no explanations or conversational text Output format: - Return STRICT JSON ONLY: {\"content\":\"...\",\"blockType\":\"{$block_type}\"} - Use content value only for the final refined block text - If list: content should contain one item per line - No markdown wrappers, no chain-of-thought, no \"Refined version\", no \"Key refinements\", no explanations"; $messages = [ [ "role" => "system", "content" => $system_prompt, ], [ "role" => "user", "content" => "Refine this content.", ], ]; // Use streaming for real-time feedback $refined_content = ""; $stream_result = $provider->chat_stream( $messages, ["temperature" => 0.2], "execution", function ($chunk) use (&$refined_content) { // Accumulate the streaming content $refined_content .= $chunk; }, ); if (is_wp_error($stream_result)) { $failed_count++; $consecutive_errors++; echo "data: " . wp_json_encode([ "type" => "error", "message" => $stream_result->get_error_message(), ]) . "\n\n"; flush(); if ($consecutive_errors >= $max_consecutive_errors) { $aborted_due_to_provider_errors = true; echo "data: " . wp_json_encode([ "type" => "status", "message" => sprintf( "Stopped early after %d consecutive provider failures at block %d. Please retry with fewer blocks or check local backend health.", (int) $consecutive_errors, (int) $block_index_loop + 1, ), ]) . "\n\n"; flush(); break; } continue; } $consecutive_errors = 0; // Track cost from streaming result (always track for debugging). $stream_cost = $stream_result["cost"] ?? 0; $last_model_used = $stream_result["model"] ?? $last_model_used; $total_cost += $stream_cost; $this->track_ai_cost( $post_id, $stream_result["model"] ?? "", "block_refinement", $stream_result["input_tokens"] ?? 0, $stream_result["output_tokens"] ?? 0, $stream_cost, $provider_result, $session_id ?? "", "success", ); // 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; if ( $this->is_contaminated_refinement_output( $refined_content, $resolved_block_type, ) ) { $failed_count++; echo "data: " . wp_json_encode([ "type" => "status", "message" => sprintf( 'Skipped contaminated output for block %1$d/%2$d (%3$s).', (int) $block_index_loop + 1, (int) $total_blocks_to_refine, $resolved_block_type, ), ]) . "\n\n"; flush(); continue; } // 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([ "type" => "block", "block" => $block_structure, ]) . "\n\n"; flush(); $refined_count++; if ( 0 === $refined_count % 5 || $refined_count === $total_blocks_to_refine ) { echo "data: " . wp_json_encode([ "type" => "status", "message" => sprintf( 'Progress: %1$d/%2$d block(s) updated (%3$d failed)', $refined_count, $total_blocks_to_refine, $failed_count, ), ]) . "\n\n"; flush(); } // Small delay between blocks usleep(100000); } // Persist refinement exchange in session history so msg counts remain accurate. if (!empty($session_id) && !empty($message)) { $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $context_service->add_message($session_id, [ "role" => "user", "content" => sanitize_text_field($message), "timestamp" => current_time("c"), ]); $context_service->add_message($session_id, [ "role" => "assistant", "content" => isset($audit_context["source"]) && "seo_audit" === sanitize_text_field( (string) $audit_context["source"], ) ? sprintf( 'Audit fix complete: %1$s; %2$d candidate block(s) inspected; %3$d block(s) changed.', !empty($audit_context["patternCount"]) ? sprintf( "%d pattern occurrence(s)", (int) $audit_context["patternCount"], ) : "audit pattern occurrence(s)", isset($audit_context["candidateBlockCount"]) ? (int) $audit_context[ "candidateBlockCount" ] : count($blocks_to_refine), (int) $refined_count, ) : sprintf( "Refined %d block(s) based on your request.", (int) $refined_count, ), "timestamp" => current_time("c"), ]); } // Send completion message with provider metadata. echo "data: " . wp_json_encode([ "type" => "complete", "refined" => $refined_count, "failed" => $failed_count, "aborted" => $aborted_due_to_provider_errors, "totalCost" => $total_cost, "provider_metadata" => $this->build_provider_metadata( $provider_result, $last_model_used, ), ]) . "\n\n"; flush(); } catch (Exception $e) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $e->getMessage(), ]) . "\n\n"; flush(); } } /** * Restrict model edit-plan actions to explicitly allowed target block IDs. * * @since 0.1.0 * * @param array $plan_json Parsed model plan response. * @param array $allowed_block_ids Block IDs allowed to be edited. * @param array $context_blocks Normalized block context list. * @return array Sanitized plan response. */ /** * Sanitize refinement edit plan. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::sanitize_refinement_edit_plan() instead. * @param array $plan_json Edit plan. * @param array $allowed_block_ids Allowed IDs. * @param array $context_blocks Context blocks. * @return array */ private function sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks, ) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::sanitize_refinement_edit_plan( $plan_json, $allowed_block_ids, $context_blocks, ); } /** * 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. */ /** * Find block by client ID (recursive). * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::find_block_by_client_id() instead. * @param array $blocks Parsed blocks. * @param string $client_id Client ID to find. * @return array|null */ private function find_block_by_client_id($blocks, $client_id) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::find_block_by_client_id( $blocks, $client_id, ); } /** * 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. */ /** * Find block index by client ID. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::find_block_index() instead. * @param array $blocks Parsed blocks. * @param string $client_id Client ID to find. * @return int */ private function find_block_index($blocks, $client_id) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::find_block_index( $blocks, $client_id, ); } /** * Extract content from block data. * * @since 0.1.0 * @param array $block Block data. * @return string Block content. */ /** * Extract block content. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_block_content() instead. * @param array $block Block data. * @return string */ private function extract_block_content($block) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::extract_block_content($block); } /** * Extract heading from block. * * @since 0.1.0 * @param array $block Block data. * @return array|null Heading data or null if not a heading. */ /** * Extract heading from block. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_heading_from_block() instead. * @param array $block Block data. * @return array|null */ private function extract_heading_from_block($block) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::extract_heading_from_block( $block, ); } /** * Clean refined content by removing conversational text. * * @since 0.1.0 * @param string $content Content to clean. * @return string Cleaned content. */ /** * Clean refined content. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::clean_refined_content() instead. * @param string $content Content to clean. * @return string */ private function clean_refined_content($content) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::clean_refined_content( $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. */ /** * Parse refined payload. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::parse_refined_payload() instead. * @param string $content Raw content. * @return array */ private function parse_refined_payload($content) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::parse_refined_payload( $content, ); } /** * Detect assistant/meta chatter that should never be inserted as block content. * * @since 0.1.0 * @param string $content Refined content candidate. * @param string $block_type Resolved block type. * @return bool */ /** * Check if content is contaminated refinement output. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::is_contaminated_refinement_output() instead. * @param string $content Content to check. * @param string $block_type Block type. * @return bool */ private function is_contaminated_refinement_output( $content, $block_type = "core/paragraph", ) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::is_contaminated_refinement_output( $content, $block_type, ); } /** * Build a compact section-scoped context window around a block. * * @since 0.1.0 * @param array $all_blocks All serialized editor blocks. * @param int $block_index Current block index. * @param int $max_snippets Max context snippets. * @return string */ /** * Build section context for block. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::build_section_context_for_block() instead. * @param array $all_blocks All blocks. * @param int $block_index Block index. * @param int $max_snippets Max snippets. * @return string */ private function build_section_context_for_block( $all_blocks, $block_index, $max_snippets = 4, ) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::build_section_context_for_block( $all_blocks, $block_index, $max_snippets, ); } /** * 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. */ /** * Create block structure. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::create_block_structure() instead. * @param string $block_id Block ID. * @param string $block_type Block type. * @param string $content Content. * @return array */ private function create_block_structure($block_id, $block_type, $content) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::create_block_structure( $block_id, $block_type, $content, ); } /** * Build a short memory summary from the plan JSON. * * @since 0.1.0 * @param array $plan_json Plan data. * @return string Summary text. */ /** * Build memory summary from plan. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan() instead. * @param array $plan_json Plan data. * @return string */ private function build_memory_summary_from_plan($plan_json) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan( $plan_json, ); } /** * Update per-post memory meta. * * @since 0.1.0 * @param int $post_id Post ID. * @param array $data Memory fields to update. * @return void */ public 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 = []; } $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. */ /** * Get post memory context. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::get_post_memory_context() instead. * @param int $post_id Post ID. * @return string */ public function get_post_memory_context($post_id) { $bundle = $this->get_post_context_bundle($post_id); $memory = $bundle["memory"]; if (empty($memory) || !is_array($memory)) { return ""; } $lines = []; 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. */ /** * Select blocks from post. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::select_blocks() instead. * @return array */ private function select_blocks() { global $post; if (!$post) { return []; } require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::select_blocks($post->ID); } /** * Serialize block object for consistent handling. * * @since 0.1.0 * @param array $block Block data. * @return array Serialized block with clientId. */ /** * Serialize block. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::serialize_block() instead. * @param array $block Block data. * @return array */ private function serialize_block($block) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::serialize_block($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. */ /** * Extract block content from attrs. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_block_content_from_attrs() instead. * @param string $block_type Block type. * @param array $attrs Block attributes. * @return string */ public function extract_block_content_from_attrs($block_type, $attrs) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::extract_block_content_from_attrs( $block_type, $attrs, ); } /** * Handle SEO audit request. * * @since 0.1.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ /** * Scan post content for common AI-ish writing patterns. * * @param string $raw_content Raw post content. * @return array{count:int,examples:array} */ /** * Scan for AI-ish patterns. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::scan_ai_ish_patterns() instead. * @param string $raw_content Raw content. * @return array */ public function scan_ai_ish_patterns($raw_content) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::scan_ai_ish_patterns( $raw_content, ); } /** * Refine current post title based on user instruction. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_refine_title($request) { $params = $request->get_json_params(); $post_id = isset($params["postId"]) ? (int) $params["postId"] : 0; $instruction = sanitize_text_field($params["instruction"] ?? ""); $session_id = sanitize_text_field($params["sessionId"] ?? ""); if ($post_id <= 0) { return new WP_Error( "invalid_post", __("Invalid post ID.", "wp-agentic-writer"), ["status" => 400], ); } if (!$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } if ("" === $instruction) { return new WP_Error( "missing_instruction", __("Title instruction is required.", "wp-agentic-writer"), ["status" => 400], ); } $post = get_post($post_id); if (!$post) { return new WP_Error( "post_not_found", __("Post not found.", "wp-agentic-writer"), ["status" => 404], ); } $current_title = trim(wp_strip_all_tags((string) $post->post_title)); $post_config = $this->get_post_config($post_id); $focus_keyword = trim( (string) ($post_config["seo_focus_keyword"] ?? ""), ); $system_prompt = "You are an expert SEO copy editor for article titles.\n" . "Rewrite the title based on instruction.\n" . "Return ONLY the final title text.\n" . "No quotes. No explanation. No markdown."; $user_prompt = "Current title: " . ("" !== $current_title ? $current_title : "(empty)") . "\n" . "Focus keyword: " . ("" !== $focus_keyword ? $focus_keyword : "(not set)") . "\n" . "Instruction: " . $instruction . "\n" . "Constraints: keep it concise, natural, and publish-ready."; $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "refinement", ); $provider = $provider_result->provider; $response = $provider->chat( [ ["role" => "system", "content" => $system_prompt], ["role" => "user", "content" => $user_prompt], ], ["post_id" => $post_id], "refinement", ); if (is_wp_error($response)) { return $response; } $new_title = trim( wp_strip_all_tags((string) ($response["content"] ?? "")), ); $new_title = preg_replace("/\s+/", " ", $new_title); if ("" === $new_title) { return new WP_Error( "empty_title", __("Refined title is empty.", "wp-agentic-writer"), ["status" => 500], ); } wp_update_post([ "ID" => $post_id, "post_title" => $new_title, ]); $this->track_ai_cost( $post_id, $response["model"] ?? "", "title_refinement", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $response["cost"] ?? 0, $provider_result, $session_id, "success", ); return new WP_REST_Response( [ "title" => $new_title, "cost" => $response["cost"] ?? 0, "provider_metadata" => $this->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ], 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. */ public function suggest_internal_links( $post_id, $focus_keyword = "", $limit = 3, ) { $suggestions = []; // Get all published posts except current $args = [ "post_type" => "post", "post_status" => "publish", "posts_per_page" => 50, "post__not_in" => [$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, ["fields" => "ids"]); $post_tags = wp_get_post_tags($post->ID, ["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[] = [ "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_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "clarity", ); $provider = $provider_result->provider; $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 = [ [ "role" => "user", "content" => $prompt, ], ]; $response = $provider->chat( $messages, ["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) { $this->track_ai_cost( $post_id, $response["model"] ?? "unknown", "meta_description", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $cost, $provider_result, $session_id ?? "", "success", ); } return [ "meta_description" => $meta_description, "length" => strlen($meta_description), "cost" => $cost, ]; } /** * 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; // Check post permission before using postId for cost tracking. if ($post_id > 0 && !$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } if (empty($last_message)) { return new WP_REST_Response(["intent" => "continue_chat"], 200); } $normalized_message = strtolower((string) $last_message); if ( preg_match( "/\b(?:out?line|plan|structure|kerangka|rencana)(?:\s*[- ]?\s*(?:nya|kan))?\b/u", $normalized_message, ) ) { return new WP_REST_Response( [ "intent" => "create_outline", "cost" => 0, ], 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. \"add_section\" - User wants to add a new section to existing outline or article 5. \"continue_chat\" - User wants to continue discussing/exploring 6. \"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 clarity model for intent detection $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( "clarity", ); $provider = $provider_result->provider; $messages = [ [ "role" => "user", "content" => $prompt, ], ]; $response = $provider->chat($messages, [], "intent_detection"); if (is_wp_error($response)) { return $response; } // Track cost $this->track_ai_cost( $post_id, $response["model"] ?? "", "detect_intent", $response["input_tokens"] ?? 0, $response["output_tokens"] ?? 0, $response["cost"] ?? 0, $provider_result, $session_id ?? "", "success", ); // Clean up response $intent = trim(strtolower($response["content"] ?? "continue_chat")); $intent = str_replace('"', "", $intent); // Validate intent $valid_intents = [ "create_outline", "start_writing", "refine_content", "add_section", "continue_chat", "clarify", ]; if (!in_array($intent, $valid_intents, true)) { $intent = "continue_chat"; } return new WP_REST_Response( [ "intent" => $intent, "cost" => $response["cost"] ?? 0, "provider_metadata" => $this->build_provider_metadata( $provider_result, $response["model"] ?? "", ), ], 200, ); } /** * Handle get image recommendations request. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_get_image_recommendations($request) { $post_id = $request->get_param("post_id"); if ($post_id > 0 && !$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $images = $image_manager->get_image_recommendations($post_id); // Block-level sync: ensure each unresolved image block has a stable // agent id and a corresponding recommendation row. if ($post_id > 0) { $post = get_post($post_id); if ($post instanceof WP_Post && !empty($post->post_content)) { $post_config = $this->get_post_config($post_id); if (!empty($post_config["include_images"])) { $images = $this->sync_image_block_recommendations( $post_id, $post, ); } } } return new WP_REST_Response(["images" => $images], 200); } /** * Ensure unresolved image blocks are mapped 1:1 to recommendation rows. * * @param int $post_id Post ID. * @param WP_Post $post Post object. * @return array */ public function sync_image_block_recommendations($post_id, $post) { $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $post_content = (string) $post->post_content; $blocks = parse_blocks($post_content); $changed = false; $slots = []; $slot_index = 0; $post_title = trim(wp_strip_all_tags((string) $post->post_title)); $walk = function (&$items, $heading_context = "") use ( &$walk, &$changed, &$slots, &$slot_index, $post_id, $post_title, ) { foreach ($items as &$block) { $name = $block["blockName"] ?? ""; $attrs = $block["attrs"] ?? []; if ("core/heading" === $name) { $heading = ""; if (!empty($attrs["content"])) { $heading = trim( wp_strip_all_tags((string) $attrs["content"]), ); } elseif (!empty($block["innerHTML"])) { $heading = trim( wp_strip_all_tags((string) $block["innerHTML"]), ); } if ("" !== $heading) { $heading_context = $heading; } } if ("core/image" === $name) { $image_id = isset($attrs["id"]) ? (int) $attrs["id"] : 0; if ($image_id <= 0) { $slot_index++; $agent_id = isset($attrs["data-agent-image-id"]) ? trim((string) $attrs["data-agent-image-id"]) : ""; if ("" === $agent_id) { $agent_id = "img_" . $post_id . "_blk_" . $slot_index . "_" . substr( wp_hash(microtime(true) . wp_rand()), 0, 8, ); $attrs["data-agent-image-id"] = $agent_id; $class_name = isset($attrs["className"]) ? (string) $attrs["className"] : ""; if ( false === strpos($class_name, "wpaw-agent-img-") ) { $attrs["className"] = trim( $class_name . " wpaw-agent-img-" . $agent_id, ); } $block["attrs"] = $attrs; $changed = true; } $slots[] = [ "agent_image_id" => $agent_id, "section_title" => "" !== $heading_context ? $heading_context : ("" !== $post_title ? $post_title : "Article Section"), "slot_index" => $slot_index, ]; } } if ( !empty($block["innerBlocks"]) && is_array($block["innerBlocks"]) ) { $walk($block["innerBlocks"], $heading_context); } } unset($block); }; $walk($blocks, ""); if ($changed) { $serialized = serialize_blocks($blocks); if ($serialized !== $post_content) { wp_update_post([ "ID" => $post_id, "post_content" => $serialized, ]); } } $current_images = $image_manager->get_image_recommendations($post_id); $by_agent_id = []; $existing_rows = []; if (is_array($current_images)) { foreach ($current_images as $row) { $key = isset($row["agent_image_id"]) ? (string) $row["agent_image_id"] : ""; if ("" !== $key) { $by_agent_id[$key] = true; } $existing_rows[] = $row; } } $slot_agent_ids = []; foreach ($slots as $slot) { $slot_agent_ids[$slot["agent_image_id"]] = true; } $orphan_rows = []; foreach ($existing_rows as $row) { $key = isset($row["agent_image_id"]) ? (string) $row["agent_image_id"] : ""; if ("" !== $key && !isset($slot_agent_ids[$key])) { $orphan_rows[] = $row; } } $focus_variants = [ "establishing scene", "close-up detail", "human activity and impact", "before-and-after comparison", "infographic-like composition", ]; foreach ($slots as $slot) { $agent_id = $slot["agent_image_id"]; if (isset($by_agent_id[$agent_id])) { continue; } $focus = $focus_variants[ ((int) $slot["slot_index"] - 1) % count($focus_variants) ]; $section_title = $slot["section_title"]; $prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . ". Realistic editorial style, informative composition, natural lighting, high detail."; if (!empty($orphan_rows)) { $orphan = array_shift($orphan_rows); if (isset($orphan["id"])) { global $wpdb; $table = $wpdb->prefix . "wpaw_images"; $wpdb->update( $table, [ "agent_image_id" => $agent_id, "placement" => "slot_" . (int) $slot["slot_index"], "section_title" => $section_title, "prompt_initial" => $prompt, "alt_text_initial" => "Gambar untuk bagian: " . $section_title, ], [ "id" => (int) $orphan["id"], "post_id" => (int) $post_id, ], ["%s", "%s", "%s", "%s", "%s"], ["%d", "%d"], ); $by_agent_id[$agent_id] = true; continue; } } $image_manager->save_image_recommendation( $post_id, $agent_id, "slot_" . (int) $slot["slot_index"], $section_title, $prompt, "Gambar untuk bagian: " . $section_title, ); } $result = $image_manager->get_image_recommendations($post_id); return is_array($result) ? $result : []; } /** * Seed deterministic image recommendations from post content. * * @param int $post_id Post ID. * @param string $post_title Post title. * @param string $post_content Post content. * @return bool True when at least one recommendation is saved. */ private function seed_basic_image_recommendations( $post_id, $post_title, $post_content, ) { $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $existing = $image_manager->get_image_recommendations($post_id); if (is_array($existing) && !empty($existing)) { return true; } $max_images = 3; $title = trim(wp_strip_all_tags((string) $post_title)); $seeded = 0; if ("" !== $title) { $agent_image_id = "img_" . $post_id . "_" . time() . "_hero"; $image_manager->save_image_recommendation( $post_id, $agent_image_id, "hero", $title, "Editorial hero image illustrating: " . $title . ". Documentary style, natural lighting, high detail.", "Ilustrasi utama artikel: " . $title, ); $seeded++; } $headings = []; if ( preg_match_all( "/]*>(.*?)<\/h[2-4]>/i", $post_content, $matches, ) ) { foreach ($matches[1] as $heading) { $clean = trim(wp_strip_all_tags($heading)); if ("" !== $clean) { $headings[] = $clean; } if (count($headings) >= $max_images - 1) { break; } } } foreach ($headings as $index => $heading) { $agent_image_id = "img_" . $post_id . "_" . time() . "_sec_" . ($index + 1); $image_manager->save_image_recommendation( $post_id, $agent_image_id, "section_" . ($index + 1), $heading, 'Contextual supporting image for section "' . $heading . '". Realistic scene, informative composition, editorial quality.', "Gambar pendukung untuk bagian: " . $heading, ); $seeded++; } return $seeded > 0; } /** * Ensure recommendations exist for every unresolved image block. * * @param int $post_id Post ID. * @param string $post_content Post content. * @param string $post_title Post title. * @return void */ private function ensure_recommendations_for_image_blocks( $post_id, $post_content, $post_title, ) { $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $current_images = $image_manager->get_image_recommendations($post_id); $current_count = is_array($current_images) ? count($current_images) : 0; $image_slots = $this->extract_unresolved_image_slots($post_content); $target_count = count($image_slots); if ($target_count <= $current_count) { return; } $fallback_title = trim(wp_strip_all_tags((string) $post_title)); for ($i = $current_count; $i < $target_count; $i++) { $slot_title = isset($image_slots[$i]["section_title"]) ? $image_slots[$i]["section_title"] : ""; $section_title = "" !== $slot_title ? $slot_title : ("" !== $fallback_title ? $fallback_title : "Article Section"); $agent_image_id = "img_" . $post_id . "_" . time() . "_slot_" . ($i + 1); $focus_variants = [ "establishing scene", "close-up detail", "human activity and impact", "before-and-after comparison", "infographic-like composition", ]; $focus = $focus_variants[$i % count($focus_variants)]; $prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . ". Realistic editorial style, informative composition, natural lighting, high detail."; $image_manager->save_image_recommendation( $post_id, $agent_image_id, "slot_" . ($i + 1), $section_title, $prompt, "Gambar untuk bagian: " . $section_title, ); } } /** * Extract unresolved image slots with nearest heading context. * * @param string $post_content Post content. * @return array */ /** * Extract unresolved image slots. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_unresolved_image_slots() instead. * @param string $post_content Post content. * @return array */ private function extract_unresolved_image_slots($post_content) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::extract_unresolved_image_slots( $post_content, ); } /** * Handle generate image request. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_generate_image($request) { // Check rate limit. $rate_limit = WPAW_Rate_Limiter::check("generate_image"); if (is_wp_error($rate_limit)) { return $rate_limit; } $post_id = $request->get_param("post_id"); $agent_image_id = $request->get_param("agent_image_id"); $prompt = $request->get_param("prompt"); $variant_count = $request->get_param("variant_count") ?? 2; if ($post_id > 0 && !$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $variants = $image_manager->generate_image_variants( $post_id, $agent_image_id, $prompt, $variant_count, ); if (is_wp_error($variants)) { return $variants; } return new WP_REST_Response(["variants" => $variants], 200); } /** * Handle commit image request. * * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_commit_image($request) { $post_id = $request->get_param("post_id"); $agent_image_id = $request->get_param("agent_image_id"); $variant_id = $request->get_param("variant_id"); $alt_text = $request->get_param("alt"); if ($post_id > 0 && !$this->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $result = $image_manager->commit_image_variant( $post_id, $agent_image_id, $variant_id, $alt_text, ); if (is_wp_error($result)) { return $result; } return new WP_REST_Response($result, 200); } /** * Parse refined blocks from AI response. * * @param string $content AI response content. * @param int $expected_count Expected number of blocks. * @return array Array of block contents. */ /** * Parse refined blocks. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::parse_refined_blocks() instead. * @param string $content Content. * @param int $expected_count Expected count. * @return array */ public function parse_refined_blocks($content, $expected_count = 0) { require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php"; return WP_Agentic_Writer_Sidebar_Helpers::parse_refined_blocks( $content, $expected_count, ); } /** * Handle generate title request. * * Uses WordPress 7.0 AI Client when available, falls back to legacy. * * @since 0.1.4 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_generate_title($request) { $params = $request->get_json_params(); $content = sanitize_textarea_field($params["content"] ?? ""); if (empty($content)) { return new WP_Error( "missing_content", __( "Content is required for title generation.", "wp-agentic-writer", ), ["status" => 400], ); } $options = [ "post_id" => isset($params["post_id"]) ? (int) $params["post_id"] : 0, ]; $client = WPAW_WP_AI_Client::get_instance(); $result = $client->generate_title($content, $options); if (is_wp_error($result)) { return $result; } return new WP_REST_Response( [ "title" => $result, "source" => $client->get_ai_mode(), ], 200, ); } /** * Handle generate excerpt request. * * Uses WordPress 7.0 AI Client when available, falls back to legacy. * * @since 0.1.4 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_generate_excerpt($request) { $params = $request->get_json_params(); $content = sanitize_textarea_field($params["content"] ?? ""); if (empty($content)) { return new WP_Error( "missing_content", __( "Content is required for excerpt generation.", "wp-agentic-writer", ), ["status" => 400], ); } $options = [ "post_id" => isset($params["post_id"]) ? (int) $params["post_id"] : 0, ]; $client = WPAW_WP_AI_Client::get_instance(); $result = $client->generate_excerpt($content, $options); if (is_wp_error($result)) { return $result; } return new WP_REST_Response( [ "excerpt" => $result, "source" => $client->get_ai_mode(), ], 200, ); } /** * Handle get AI capabilities request. * * Returns the current AI capabilities based on available providers. * * @since 0.1.4 * @param WP_REST_Request $request REST request. * @return WP_REST_Response */ public function handle_get_ai_capabilities($request) { $client = WPAW_WP_AI_Client::get_instance(); $capabilities = $client->get_capabilities(); return new WP_REST_Response($capabilities, 200); } /** * Handle search request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_search($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php"; $ctrl = new WP_Agentic_Writer_Controller_Chat($this); return $ctrl->handle_search($request); } /** * Handle fetch content request for research. * * Fetches and extracts content from a URL for AI context. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_fetch_content($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php"; $ctrl = new WP_Agentic_Writer_Controller_Chat($this); return $ctrl->handle_fetch_content($request); } /** * Handle research summary request. * * Performs multiple searches and generates a research summary. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_research_summary($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php"; $ctrl = new WP_Agentic_Writer_Controller_Chat($this); return $ctrl->handle_research_summary($request); } /** * Handle get conversations request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_get_conversations($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-conversation.php"; $ctrl = new WP_Agentic_Writer_Controller_Conversation($this); return $ctrl->handle_get_conversations($request); } /** * Handle create conversation request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_create_conversation($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-conversation.php"; $ctrl = new WP_Agentic_Writer_Controller_Conversation($this); return $ctrl->handle_create_conversation($request); } /** * Handle get single conversation request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_get_conversation($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-conversation.php"; $ctrl = new WP_Agentic_Writer_Controller_Conversation($this); return $ctrl->handle_get_conversation($request); } /** * Restore rich plan UI payloads for sessions that only stored a text summary. * * @since 0.2.2 * @param array $session Conversation session. * @return array */ private function hydrate_session_plan_messages($session) { if (!is_array($session)) { return $session; } $post_id = isset($session["post_id"]) ? (int) $session["post_id"] : 0; if ( $post_id <= 0 || empty($session["messages"]) || !is_array($session["messages"]) ) { return $session; } foreach ($session["messages"] as $message) { if ( isset($message["type"]) && "plan" === $message["type"] && !empty($message["plan"]) ) { return $session; } } $plan = get_post_meta($post_id, "_wpaw_plan", true); if (!is_array($plan)) { return $session; } foreach ($session["messages"] as $index => $message) { $content = isset($message["content"]) ? (string) $message["content"] : ""; $role = isset($message["role"]) ? (string) $message["role"] : ""; if ( "assistant" !== $role || false === strpos($content, "Outline ready.") ) { continue; } $session["messages"][$index]["type"] = "plan"; $session["messages"][$index]["plan"] = $plan; break; } return $session; } /** * Handle update conversation request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_update_conversation($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-conversation.php"; $ctrl = new WP_Agentic_Writer_Controller_Conversation($this); return $ctrl->handle_update_conversation($request); } /** * Handle delete conversation request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_delete_conversation($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-conversation.php"; $ctrl = new WP_Agentic_Writer_Controller_Conversation($this); return $ctrl->handle_delete_conversation($request); } /** * Handle update conversation messages request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_update_conversation_messages($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-conversation.php"; $ctrl = new WP_Agentic_Writer_Controller_Conversation($this); return $ctrl->handle_update_conversation_messages($request); } /** * Handle link conversation to post request. * * @since 0.1.4 * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_link_conversation_to_post($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-conversation.php"; $ctrl = new WP_Agentic_Writer_Controller_Conversation($this); return $ctrl->handle_link_conversation_to_post($request); } /** * Handle migrate chat history request. * * Migrates legacy _wpaw_chat_history from post meta to session table. * * @since 0.1.4 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_migrate_chat_history($request) { $post_id = (int) $request->get_param("post_id"); if ($post_id <= 0) { return new WP_Error( "invalid_post", __("Valid post ID is required.", "wp-agentic-writer"), ["status" => 400], ); } // Verify post exists and user can edit if (!current_user_can("edit_post", $post_id)) { return new WP_Error( "permission_denied", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } // Use Context Service for migration $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $result = $context_service->migrate_legacy_chat_history($post_id); if (!$result) { return new WP_Error( "migration_failed", __("Failed to migrate chat history.", "wp-agentic-writer"), ["status" => 500], ); } // Return migration status $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); $sessions = $manager->get_sessions_for_post($post_id); return new WP_REST_Response( [ "migrated" => true, "post_id" => $post_id, "sessions_count" => count($sessions), "message" => "Legacy chat history has been migrated to session table.", ], 200, ); } /** * Auto-save post and link conversation when writing execution begins. * * @since 0.1.4 * @param string $session_id Session ID. * @param int $post_id Current post ID (can be 0). * @return int New post ID if saved, or original if not needed. */ public function ensure_conversation_linked_to_post( $session_id, $post_id = 0, ) { $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); // Already linked if ($post_id > 0) { return $post_id; } // Check if editor has content if (!$manager->post_has_content(get_the_ID())) { // No content yet, keep as post_id = 0 return 0; } // Get current post (auto-save with placeholder title) $current_post_id = get_the_ID(); if ($current_post_id && $current_post_id > 0) { // Update post with placeholder title if needed $post = get_post($current_post_id); if ($post && empty($post->post_title)) { wp_update_post([ "ID" => $current_post_id, "post_title" => "Draft - " . date("Y-m-d H:i"), ]); } // Link conversation to post $manager->link_to_post($session_id, $current_post_id); return $current_post_id; } return 0; } /** * Get user preferences (per-user settings). * * @since 0.2.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_get_user_preferences($request) { $user_id = get_current_user_id(); // Return defaults if not logged in if ($user_id === 0) { return new WP_REST_Response( [ "proactive_suggestions" => true, "command_palette_enabled" => true, "outline_panel_enabled" => true, "auto_save_interval" => 30, "preferred_model" => "", "preferred_language" => "auto", "theme" => "dark", ], 200, ); } $preferences = get_user_meta($user_id, "wpaw_user_preferences", true); // Merge with defaults $defaults = [ "proactive_suggestions" => true, "command_palette_enabled" => true, "outline_panel_enabled" => true, "auto_save_interval" => 30, "preferred_model" => "", "preferred_language" => "auto", "theme" => "dark", ]; $preferences = is_array($preferences) ? array_merge($defaults, $preferences) : $defaults; return new WP_REST_Response($preferences, 200); } /** * Save user preferences (per-user settings). * * @since 0.2.0 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_save_user_preferences($request) { $user_id = get_current_user_id(); if ($user_id === 0) { return new WP_Error( "unauthorized", __( "You must be logged in to save preferences.", "wp-agentic-writer", ), ["status" => 401], ); } $preferences = $request->get_json_params(); // Validate and sanitize $sanitized = [ "proactive_suggestions" => !empty( $preferences["proactive_suggestions"] ), "command_palette_enabled" => !empty( $preferences["command_palette_enabled"] ), "outline_panel_enabled" => !empty( $preferences["outline_panel_enabled"] ), "auto_save_interval" => max( 5, min(300, (int) ($preferences["auto_save_interval"] ?? 30)), ), "preferred_model" => sanitize_text_field( $preferences["preferred_model"] ?? "", ), "preferred_language" => sanitize_text_field( $preferences["preferred_language"] ?? "auto", ), "theme" => in_array( $preferences["theme"] ?? "dark", ["dark", "light"], true, ) ? $preferences["theme"] : "dark", ]; update_user_meta($user_id, "wpaw_user_preferences", $sanitized); // MEMANTO: Store user preferences to user agent. do_action("wpaw_memanto_config_saved", 0, $sanitized); return new WP_REST_Response($sanitized, 200); } /** * Handle MEMANTO status check. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response Status response. */ public function handle_memanto_status($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php"; $controller = new WP_Agentic_Writer_Controller_Memanto($this); return $controller->handle_memanto_status($request); } /** * Handle MEMANTO recall request. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_memanto_recall($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php"; $controller = new WP_Agentic_Writer_Controller_Memanto($this); return $controller->handle_memanto_recall($request); } /** * Handle MEMANTO session restore. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead. * @param WP_REST_Request $request REST request with post_id param. * @return WP_REST_Response|WP_Error Restore payload. */ public function handle_memanto_restore($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php"; $controller = new WP_Agentic_Writer_Controller_Memanto($this); return $controller->handle_memanto_restore($request); } /** * Handle MEMANTO user preferences recall. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response Preference payload. */ public function handle_memanto_preferences($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php"; $controller = new WP_Agentic_Writer_Controller_Memanto($this); return $controller->handle_memanto_preferences($request); } /** * Handle session lock acquire/refresh (heartbeat). * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Session instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_session_lock($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-session.php"; $controller = new WP_Agentic_Writer_Controller_Session($this); return $controller->handle_session_lock($request); } /** * Handle session lock release. * * @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Session instead. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_session_unlock($request) { require_once WPAW_PLUGIN_DIR . "includes/class-controller-session.php"; $controller = new WP_Agentic_Writer_Controller_Session($this); return $controller->handle_session_unlock($request); } }