sidebar = $sidebar; } /** * 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) { // Check rate limit. $params = $request->get_json_params(); $stream = !empty($params["stream"]); $endpoint = $stream ? "chat_stream" : "chat"; $rate_limit = WPAW_Rate_Limiter::check($endpoint); if (is_wp_error($rate_limit)) { return $rate_limit; } $messages = $params["messages"] ?? []; $post_id = $params["postId"] ?? 0; $type = $params["type"] ?? "planning"; $session_id = $this->sidebar->resolve_or_create_session_id( $params["sessionId"] ?? "", $post_id, ); // Check post permission if post_id is provided. if ($post_id > 0 && !$this->sidebar->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->sidebar->resolve_post_config_from_request( $params, $post_id, ); $post_config_context = $this->sidebar->build_post_config_context( $post_config, ); // Detect language from user's last message for real-time response matching. $last_user_message = $this->sidebar->get_last_user_message($messages); $detected_from_message = $this->sidebar->detect_language_from_text( $last_user_message, ); $stored_language = get_post_meta( $post_id, "_wpaw_detected_language", true, ); $effective_language = $this->sidebar->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->sidebar->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->sidebar->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->sidebar->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->sidebar->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->sidebar->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->sidebar->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->sidebar->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(); } // Failsafe: always use the provider's final returned content if our stream capture failed. if ( empty($accumulated_content) && !is_wp_error($response) && !empty($response["content"]) ) { $accumulated_content = $response["content"]; } error_log( "WPAW Stream Debug: Accumulated Content Length: " . strlen($accumulated_content) . " | Is Error: " . (is_wp_error($response) ? "Yes" : "No") . " | Session ID: " . $session_id, ); if (is_wp_error($response)) { echo "data: " . wp_json_encode([ "type" => "error", "message" => $response->get_error_message(), ]) . "\n\n"; flush(); exit(); } if (empty($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->sidebar->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(); $res1 = $context_service->add_message($session_id, [ "role" => "user", "content" => $last_user_message, "timestamp" => current_time("c"), ]); $res2 = $context_service->add_message($session_id, [ "role" => "assistant", "content" => $accumulated_content, "timestamp" => current_time("c"), ]); error_log( "WPAW Stream Debug: add_message result: user=" . ($res1 ? "true" : "false") . ", assistant=" . ($res2 ? "true" : "false"), ); } else { error_log( "WPAW Stream Debug: session_id is empty, skipping add_message", ); } // 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 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_clear_context($request) { $params = $request->get_json_params(); $post_id = intval($params["postId"] ?? 0); $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], ); } // Check post permission before clearing context. if (!$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to edit this post.", "wp-agentic-writer", ), ["status" => 403], ); } // Use the context service to clear the session and post meta consistently. $context_service = WP_Agentic_Writer_Context_Service::get_instance(); $context_service->clear_context($session_id, $post_id); // MEMANTO: Notify session end on context clear. do_action("wpaw_memanto_session_end", $session_id, $post_id); return new WP_REST_Response( [ "success" => true, ], 200, ); } /** * 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. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ public function handle_get_chat_history($request) { $post_id = intval($request["post_id"] ?? 0); if ($post_id <= 0) { return new WP_Error( "invalid_post", __("Invalid post ID.", "wp-agentic-writer"), ["status" => 400], ); } if (!$this->sidebar->check_post_permission($post_id)) { return new WP_Error( "forbidden", __( "You do not have permission to access this post.", "wp-agentic-writer", ), ["status" => 403], ); } $history = $this->sidebar->get_post_chat_history($post_id); return new WP_REST_Response( [ "messages" => $history, "deprecated" => true, "message" => "This endpoint is deprecated. Use conversation sessions instead.", ], 200, ); } /** * Handle search request. * * @since 0.1.4 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_search($request) { $params = $request->get_json_params(); $query = sanitize_text_field($params["query"] ?? ""); $count = isset($params["count"]) ? absint($params["count"]) : 5; if (empty($query)) { return new WP_Error( "missing_query", __("Search query is required.", "wp-agentic-writer"), ["status" => 400], ); } $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); $results = $brave->search($query, $count); if (is_wp_error($results)) { return $results; } return new WP_REST_Response( [ "query" => $query, "results" => $results, "count" => count($results), ], 200, ); } /** * Handle fetch content request for research. * * Fetches and extracts content from a URL for AI context. * * @since 0.1.4 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_fetch_content($request) { $params = $request->get_json_params(); $url = esc_url_raw($params["url"] ?? ""); if (empty($url)) { return new WP_Error( "missing_url", __("URL is required.", "wp-agentic-writer"), ["status" => 400], ); } // Validate URL format. if (!wp_http_validate_url($url)) { return new WP_Error( "invalid_url", __("Invalid URL provided.", "wp-agentic-writer"), ["status" => 400], ); } // Fetch the content. $response = wp_remote_get($url, [ "timeout" => 20, "user-agent" => "Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)", ]); if (is_wp_error($response)) { return $response; } $http_code = wp_remote_retrieve_response_code($response); if (200 !== $http_code) { return new WP_Error( "fetch_failed", sprintf( // translators: %d is the HTTP status code. __( "Undefined error — please try again (HTTP %d).", "wp-agentic-writer", ), $http_code, ), ["status" => $http_code], ); } $body = wp_remote_retrieve_body($response); $content = wp_strip_all_tags($body); // Truncate to prevent token overflow (max ~4000 chars for context). if (strlen($content) > 4000) { $content = substr($content, 0, 4000) . "..."; } return new WP_REST_Response( [ "url" => $url, "content" => $content, "length" => strlen($content), ], 200, ); } /** * Handle research summary request. * * Performs multiple searches and generates a research summary. * * @since 0.1.4 * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error */ public function handle_research_summary($request) { $params = $request->get_json_params(); $topic = sanitize_text_field($params["topic"] ?? ""); $depth = sanitize_text_field($params["depth"] ?? "basic"); $include_urls = isset($params["include_urls"]) ? (bool) $params["include_urls"] : false; if (empty($topic)) { return new WP_Error( "missing_topic", __("Research topic is required.", "wp-agentic-writer"), ["status" => 400], ); } // Determine search count based on depth. $search_counts = [ "basic" => 3, "medium" => 5, "deep" => 8, ]; $count = $search_counts[$depth] ?? 3; $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); // Perform main search. $main_results = $brave->search($topic, $count); if (is_wp_error($main_results)) { return $main_results; } $research_data = [ "topic" => $topic, "depth" => $depth, "search_results" => $main_results, "formatted_context" => $brave->format_results_for_llm( $main_results, $topic, ), ]; // Optionally fetch content from top URLs. if ($include_urls && !empty($main_results)) { $fetched_content = []; $max_urls = min(2, count($main_results)); // Limit to 2 URLs. for ($i = 0; $i < $max_urls; $i++) { $url = $main_results[$i]["url"] ?? ""; if (empty($url)) { continue; } $fetch_response = wp_remote_get($url, [ "timeout" => 15, "user-agent" => "Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)", ]); if ( !is_wp_error($fetch_response) && 200 === wp_remote_retrieve_response_code($fetch_response) ) { $body = wp_remote_retrieve_body($fetch_response); $content = wp_strip_all_tags($body); if (strlen($content) > 2000) { $content = substr($content, 0, 2000) . "..."; } $fetched_content[] = [ "url" => $url, "content" => $content, ]; } } if (!empty($fetched_content)) { $research_data["fetched_content"] = $fetched_content; } } return new WP_REST_Response($research_data, 200); } }