refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup: - Add new controller architecture (class-controller-*.php) - Add new settings-v2 UI (views/settings-v2/) - Add new CSS architecture (agentic-sidebar.css, tokens) - Add esbuild build pipeline (scripts/build.js, package.json) - Add composer dependencies (vendor/) - Add frontend src directory (assets/js/src/index.jsx) - Add documentation files - Remove old/obsolete files (class-settings.php, old CSS) This commits all pending changes from previous refactoring efforts.
This commit is contained in:
@@ -1,154 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* Brave Search API Integration
|
||||
*
|
||||
* Handles fetching web search results for models that do not natively support web search
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Brave_Search_API {
|
||||
|
||||
/**
|
||||
* Brave Search REST API Endpoint
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $api_endpoint = 'https://api.search.brave.com/res/v1/web/search';
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_Agentic_Writer_Brave_Search_API
|
||||
*/
|
||||
public static function get_instance() {
|
||||
static $instance = null;
|
||||
|
||||
if ( null === $instance ) {
|
||||
$instance = new self();
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a web search.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $query Required. The user's search query.
|
||||
* @param int $count Optional. Number of results to return. Default 3.
|
||||
* @return array|WP_Error Array of formatted search results, or WP_Error on failure.
|
||||
*/
|
||||
public function search( $query, $count = 3 ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$api_key = $settings['brave_search_api_key'] ?? '';
|
||||
|
||||
if ( empty( $api_key ) ) {
|
||||
return new WP_Error(
|
||||
'brave_api_key_missing',
|
||||
__( 'Brave Search API Key is missing. Please configure it in WP Agentic Writer settings.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
// Check cache first to prevent burning API limits on identical subsequent queries
|
||||
$cache_key = 'wpaw_brave_search_' . md5( $query . '_' . $count );
|
||||
$cached_results = get_transient( $cache_key );
|
||||
if ( false !== $cached_results ) {
|
||||
return $cached_results;
|
||||
}
|
||||
|
||||
$url = add_query_arg(
|
||||
array(
|
||||
'q' => urlencode( $query ),
|
||||
'count' => absint( $count ),
|
||||
'text_decorations' => 0, // Disable HTML tags in descriptions
|
||||
'spellcheck' => 1,
|
||||
),
|
||||
$this->api_endpoint
|
||||
);
|
||||
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Accept' => 'application/json',
|
||||
'Accept-Encoding' => 'gzip',
|
||||
'X-Subscription-Token' => $api_key,
|
||||
),
|
||||
'timeout' => 15,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$http_code = wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( 200 !== $http_code ) {
|
||||
return new WP_Error(
|
||||
'brave_api_error',
|
||||
sprintf(
|
||||
/* translators: %1$d is HTTP status code, %2$s is error message */
|
||||
__( 'Brave Search API Error %1$d: %2$s', 'wp-agentic-writer' ),
|
||||
$http_code,
|
||||
$body['message'] ?? __( 'Unknown Error', 'wp-agentic-writer' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ( empty( $body['web']['results'] ) ) {
|
||||
return array(); // No results found
|
||||
}
|
||||
|
||||
$formatted_results = array();
|
||||
foreach ( $body['web']['results'] as $result ) {
|
||||
$formatted_results[] = array(
|
||||
'title' => $result['title'] ?? '',
|
||||
'url' => $result['url'] ?? '',
|
||||
'description' => $result['description'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
// Cache results for 1 hour to prevent redundant API calls
|
||||
set_transient( $cache_key, $formatted_results, HOUR_IN_SECONDS );
|
||||
|
||||
return $formatted_results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats search results into a markdown context block for LLM System Prompt injection.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $results Search results array.
|
||||
* @param string $query Original query.
|
||||
* @return string Formatted markdown context string.
|
||||
*/
|
||||
public function format_results_for_llm( $results, $query ) {
|
||||
if ( empty( $results ) || is_wp_error( $results ) ) {
|
||||
return "No reliable web search results found for: {$query}";
|
||||
}
|
||||
|
||||
$markdown = "## LIVE WEB SEARCH CONTEXT\n";
|
||||
$markdown .= "> You successfully searched the internet for: \"{$query}\"\n";
|
||||
$markdown .= "> Please incorporate the following real-time data into your answer:\n\n";
|
||||
|
||||
$counter = 1;
|
||||
foreach ( $results as $item ) {
|
||||
$markdown .= "{$counter}. **{$item['title']}**\n";
|
||||
$markdown .= " URL: {$item['url']}\n";
|
||||
$markdown .= " Summary: {$item['description']}\n\n";
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$markdown .= "---------------------------\n";
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
}
|
||||
@@ -370,6 +370,14 @@ class WP_Agentic_Writer_Context_Builder
|
||||
"BACKEND CONTINUITY CONTEXT\nUse this compact WordPress-saved context as continuity. Do not assume OpenRouter remembers prior turns.";
|
||||
$sections[] = "Current task: " . sanitize_key($task);
|
||||
|
||||
// Global brand voice / context injection
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$global_context = trim($settings["global_context"] ?? "");
|
||||
if ("" !== $global_context) {
|
||||
$sections[] =
|
||||
"GLOBAL SITE CONTEXT (CRITICAL RULES):\n" . $global_context;
|
||||
}
|
||||
|
||||
// MEMANTO persistent memory injection.
|
||||
$memanto_context = $this->build_memanto_context(
|
||||
$post_id,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
799
includes/class-controller-chat.php
Normal file
799
includes/class-controller-chat.php
Normal file
@@ -0,0 +1,799 @@
|
||||
<?php
|
||||
/**
|
||||
* Chat REST Controller
|
||||
*
|
||||
* Handles chat operations including messaging, streaming, search, and research.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Chat
|
||||
*
|
||||
* REST controller for chat operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Chat
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
826
includes/class-controller-clarity.php
Normal file
826
includes/class-controller-clarity.php
Normal file
@@ -0,0 +1,826 @@
|
||||
<?php
|
||||
/**
|
||||
* Clarity REST Controller
|
||||
*
|
||||
* Handles clarity check and clarification quiz operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Clarity
|
||||
*
|
||||
* REST controller for clarity checking operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Clarity
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle check clarity request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_check_clarity($request)
|
||||
{
|
||||
$params = $request->get_json_params();
|
||||
$topic = $params["topic"] ?? "";
|
||||
$answers = $params["answers"] ?? [];
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$mode = $params["mode"] ?? "generation";
|
||||
$chat_history = $params["chatHistory"] ?? [];
|
||||
|
||||
if (empty($topic)) {
|
||||
return new WP_Error(
|
||||
"no_topic",
|
||||
__("Topic is required.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
// Check post permission BEFORE reading post data.
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
// Only read post config after permission check.
|
||||
$post_config = $this->sidebar->resolve_post_config_from_request(
|
||||
$params,
|
||||
$post_id,
|
||||
);
|
||||
$post_config_context = $this->sidebar->build_post_config_context($post_config);
|
||||
$preferred_language = $this->sidebar->resolve_language_preference(
|
||||
$post_config,
|
||||
"",
|
||||
);
|
||||
$language_hint = "";
|
||||
if ("auto" !== ($post_config["language"] ?? "auto")) {
|
||||
$language_hint = "\n\nPreferred language: {$preferred_language}. Ask questions in that language.";
|
||||
}
|
||||
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
||||
"writing",
|
||||
);
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
// Get settings.
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$enabled = $settings["enable_clarification_quiz"] ?? true;
|
||||
$threshold = $settings["clarity_confidence_threshold"] ?? "0.6";
|
||||
$required_categories = $settings["required_context_categories"] ?? [
|
||||
"target_outcome",
|
||||
"target_audience",
|
||||
"tone",
|
||||
"content_depth",
|
||||
"expertise_level",
|
||||
];
|
||||
|
||||
// If quiz is disabled, skip AI questions but still add MANDATORY config questions
|
||||
if (!$enabled) {
|
||||
$result = [
|
||||
"is_clear" => true,
|
||||
"confidence" => 1.0,
|
||||
"questions" => [],
|
||||
];
|
||||
// MANDATORY: Always add config questions (language, focus keyword)
|
||||
$result["questions"] = $this->append_config_questions(
|
||||
$result["questions"],
|
||||
$post_config,
|
||||
);
|
||||
if (!empty($result["questions"])) {
|
||||
$result["is_clear"] = false; // Force quiz for config questions
|
||||
}
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"result" => $result,
|
||||
"cost" => 0,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
// Build context from answers if available.
|
||||
$context = "";
|
||||
if (!empty($answers)) {
|
||||
$context = "\n\nPrevious answers:\n";
|
||||
foreach ($answers as $answer) {
|
||||
$context .= "- {$answer["question"]}: {$answer["answer"]}\n";
|
||||
}
|
||||
}
|
||||
|
||||
// Build chat history context for continuity.
|
||||
$chat_history_context = "";
|
||||
if (!empty($chat_history) && is_array($chat_history)) {
|
||||
$chat_history_context =
|
||||
"\n\n--- CONVERSATION HISTORY (IMPORTANT - use this context!) ---\n";
|
||||
foreach ($chat_history as $msg) {
|
||||
$role = isset($msg["role"]) ? ucfirst($msg["role"]) : "Unknown";
|
||||
$content = isset($msg["content"]) ? $msg["content"] : "";
|
||||
if (!empty($content)) {
|
||||
$chat_history_context .= "{$role}: {$content}\n\n";
|
||||
}
|
||||
}
|
||||
$chat_history_context .= "--- END CONVERSATION HISTORY ---\n";
|
||||
$chat_history_context .=
|
||||
"\nIMPORTANT: The user's current request \"" .
|
||||
$topic .
|
||||
"\" is a CONTINUATION of the above conversation. Extract topic/context from the chat history. If the conversation already discussed a specific topic, the user likely wants to create an outline for THAT topic. Do NOT ask \"what topic?\" if it's already clear from the conversation.";
|
||||
}
|
||||
|
||||
$memory_context = $this->sidebar->get_post_memory_context($post_id);
|
||||
$followup_hint = "";
|
||||
if ("refinement" === $mode && !empty($memory_context)) {
|
||||
$followup_hint =
|
||||
"\n\nThis is a follow-up request to an existing article. Use the post memory below to avoid asking generic questions already covered unless the request is ambiguous within that context.";
|
||||
}
|
||||
// Also treat chat history as follow-up context.
|
||||
if (!empty($chat_history_context)) {
|
||||
$followup_hint .=
|
||||
"\n\nThis request continues from a previous chat conversation. Use the conversation history to understand what the user wants.";
|
||||
}
|
||||
|
||||
$system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively.
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. WE ARE WRITING A WORDPRESS BLOG POST/ARTICLE. NEVER ask what media format or content type the user wants (e.g., do not ask if it's for social media, pamphlets, emails, etc.). Assume it is always a web article/blog post.
|
||||
2. DETECT LANGUAGE: Identify the user's language (Indonesian, English, etc.) and write ALL questions in that SAME language
|
||||
3. PRIORITIZE TOPIC CONTEXT: Ask about the topic/scope/platform FIRST before writing-style questions
|
||||
4. USE OPEN-TEXT for complex questions that need detailed explanations
|
||||
5. USE MULTIPLE CHOICE only for simple binary/selection questions
|
||||
|
||||
EVALUATION CATEGORIES (in priority order):
|
||||
|
||||
**TOPIC-SPECIFIC CONTEXT (Most Important):**
|
||||
1. topic_scope - What specific aspects should be covered? (e.g., for \"page builder\": platforms covered? WordPress only? Comparison? Features? Use cases?)
|
||||
2. target_platform - Which platform/tool? (WordPress/Shopify/Webflow/Generic/Multiple/etc)
|
||||
3. specific_focus - What angle? (Technical tutorial/Business benefits/Comparison/Getting started/Best practices)
|
||||
4. missing_info - What key details are unclear?
|
||||
|
||||
**WRITING CONTEXT (Secondary):**
|
||||
5. target_outcome - What should this achieve? (Education/Marketing/Sales/Comparison/Tutorial/Opinion)
|
||||
6. target_audience - Who reads this? (Beginners/Developers/Business owners/Marketers/General audience)
|
||||
7. content_depth - How detailed? (Quick overview/Standard guide/Comprehensive/Technical deep-dive)
|
||||
|
||||
QUESTION TYPES:
|
||||
|
||||
1. **single_choice** - For simple selections (one answer):
|
||||
Use for: platform, outcome, audience type, depth level
|
||||
Example:
|
||||
{
|
||||
'id': 'q1',
|
||||
'category': 'target_platform',
|
||||
'question': 'Platform apa yang ingin dibahas? (What platform to focus on?)',
|
||||
'type': 'single_choice',
|
||||
'options': [
|
||||
{ 'value': 'WordPress only', 'default': true },
|
||||
{ 'value': 'Comparison: WordPress vs others', 'default': false },
|
||||
{ 'value': 'General page builders (multiple platforms)', 'default': false },
|
||||
{ 'value': 'Specific platform (mention below)', 'default': false }
|
||||
]
|
||||
}
|
||||
|
||||
2. **multiple_choice** - For selecting multiple items:
|
||||
Use for: topics to cover, platforms to compare
|
||||
Example:
|
||||
{
|
||||
'id': 'q2',
|
||||
'category': 'topic_scope',
|
||||
'question': 'Apa yang harus dibahas? (What to cover?)',
|
||||
'type': 'multiple_choice',
|
||||
'options': [
|
||||
{ 'value': 'Benefits/advantages', 'default': true },
|
||||
{ 'value': 'How to choose', 'default': true },
|
||||
{ 'value': 'Popular plugins/platforms', 'default': false },
|
||||
{ 'value': 'Use cases/examples', 'default': false },
|
||||
{ 'value': 'Technical details', 'default': false }
|
||||
]
|
||||
}
|
||||
|
||||
3. **open_text** - For detailed explanations (RECOMMENDED for complex topics):
|
||||
Use for: scope clarification, specific requirements, custom details
|
||||
Example:
|
||||
{
|
||||
'id': 'q3',
|
||||
'category': 'topic_scope',
|
||||
'question': 'Jelaskan lebih detail apa yang ingin Anda bahas tentang page builder. Apakah ada platform spesifik? Fokus ke mana? (Explain in detail what you want to cover about page builders. Any specific platform? What focus?)',
|
||||
'type': 'open_text',
|
||||
'placeholder': 'Contoh: Fokus ke WordPress plugin seperti Elementor, Divi, Brizy. Jelaskan kelebihan masing-masing...',
|
||||
'max_length': 500
|
||||
}
|
||||
|
||||
CONFIDENCE CALCULATION:
|
||||
- Start at 100% (1.0)
|
||||
- Subtract 15% for each missing HIGH-PRIORITY category (topic_scope, target_platform, specific_focus)
|
||||
- Subtract 10% for each missing SECONDARY category (target_outcome, target_audience, content_depth)
|
||||
- CRITICAL: If chat history exists with detailed discussion, ADD 20% confidence bonus (user already provided context)
|
||||
- If confidence < {$threshold}, generate questions starting with HIGH-PRIORITY
|
||||
- MANDATORY: Even if confidence >= threshold, ALWAYS ask at least 1-2 system config questions (language, SEO settings)
|
||||
|
||||
QUESTION GENERATION STRATEGY:
|
||||
1. Always detect user language first and match it
|
||||
2. If topic is vague (e.g., \"page builder\" without platform), ask open_text about scope
|
||||
3. If multiple possible platforms, ask single_choice to narrow down
|
||||
4. If multiple topics could apply, ask multiple_choice
|
||||
5. Only ask writing-style questions if topic context is already clear
|
||||
|
||||
Return ONLY valid JSON:
|
||||
{
|
||||
'is_clear': true/false,
|
||||
'confidence': 0.0-1.0,
|
||||
'detected_language': 'indonesian'|'english'|'other',
|
||||
'missing_categories': ['topic_scope', 'target_platform'],
|
||||
'questions': [ ... ]
|
||||
}
|
||||
|
||||
No markdown, no explanation - just JSON.";
|
||||
|
||||
$messages = [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => $system_prompt,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" =>
|
||||
"Topic: {$topic}\n\nRequired Categories: " .
|
||||
implode(", ", $required_categories) .
|
||||
"\n\nEvaluate this request and determine which context is missing.{$chat_history_context}{$context}{$post_config_context}{$memory_context}{$followup_hint}{$language_hint}",
|
||||
],
|
||||
];
|
||||
|
||||
$response = $provider->chat(
|
||||
$messages,
|
||||
["temperature" => 0.7],
|
||||
"planning",
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
// Track failed attempt for observability.
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
WPAW_Model_Registry::get_default_model("clarity"),
|
||||
"clarity_check",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$provider_result,
|
||||
"",
|
||||
"error",
|
||||
);
|
||||
// Log error and use default questions instead of failing.
|
||||
error_log(
|
||||
"WP Agentic Writer: Clarity check API error - " .
|
||||
$response->get_error_message(),
|
||||
);
|
||||
$result = $this->get_default_clarification_questions($topic);
|
||||
// MANDATORY: Always add config questions
|
||||
$result["questions"] = $this->append_config_questions(
|
||||
$result["questions"] ?? [],
|
||||
$post_config,
|
||||
);
|
||||
if (!empty($result["questions"])) {
|
||||
$result["is_clear"] = false;
|
||||
}
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"result" => $result,
|
||||
"cost" => 0,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
// Extract JSON from response.
|
||||
$content = $response["content"];
|
||||
$result = $this->sidebar->extract_json($content);
|
||||
|
||||
if (null === $result) {
|
||||
// Track parse failure for observability.
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$response["model"] ?? "unknown",
|
||||
"clarity_check",
|
||||
$response["input_tokens"] ?? 0,
|
||||
$response["output_tokens"] ?? 0,
|
||||
$response["cost"] ?? 0,
|
||||
$provider_result,
|
||||
"",
|
||||
"error",
|
||||
);
|
||||
// Log parse error and use default questions instead of failing.
|
||||
error_log("WP Agentic Writer: Failed to parse clarity check JSON");
|
||||
$result = $this->get_default_clarification_questions($topic);
|
||||
// MANDATORY: Always add config questions
|
||||
$result["questions"] = $this->append_config_questions(
|
||||
$result["questions"] ?? [],
|
||||
$post_config,
|
||||
);
|
||||
if (!empty($result["questions"])) {
|
||||
$result["is_clear"] = false;
|
||||
}
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"result" => $result,
|
||||
"cost" => 0,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
// Track cost (always track for debugging).
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$response["model"] ?? "",
|
||||
"clarity_check",
|
||||
$response["input_tokens"] ?? 0,
|
||||
$response["output_tokens"] ?? 0,
|
||||
$response["cost"] ?? 0,
|
||||
$provider_result,
|
||||
"",
|
||||
"success",
|
||||
);
|
||||
|
||||
// MANDATORY: Always add configuration questions
|
||||
if (!isset($result["questions"]) || !is_array($result["questions"])) {
|
||||
$result["questions"] = [];
|
||||
}
|
||||
$result["questions"] = $this->append_config_questions(
|
||||
$result["questions"],
|
||||
$post_config,
|
||||
);
|
||||
|
||||
// CRITICAL: Always show quiz if config questions exist (system questions are MANDATORY)
|
||||
if (!empty($result["questions"])) {
|
||||
$result["is_clear"] = false; // Force quiz to show - config questions are mandatory
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"result" => $result,
|
||||
"cost" => $response["cost"] ?? 0,
|
||||
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
||||
$provider_result,
|
||||
$response["model"] ?? "",
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check clarity before article generation.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @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.
|
||||
*/
|
||||
public function check_clarity_before_generation(
|
||||
$topic,
|
||||
$answers,
|
||||
$provider,
|
||||
) {
|
||||
// Get settings.
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$enabled = $settings["enable_clarification_quiz"] ?? true;
|
||||
$threshold = $settings["clarity_confidence_threshold"] ?? "0.6";
|
||||
$required_categories = $settings["required_context_categories"] ?? [
|
||||
"target_outcome",
|
||||
"target_audience",
|
||||
"tone",
|
||||
"content_depth",
|
||||
"expertise_level",
|
||||
];
|
||||
|
||||
// If quiz is disabled, always return clear.
|
||||
if (!$enabled) {
|
||||
return ["is_clear" => true, "confidence" => 1.0, "questions" => []];
|
||||
}
|
||||
|
||||
// Build context from answers if available.
|
||||
$context = "";
|
||||
if (!empty($answers)) {
|
||||
$context = "\n\nPrevious answers:\n";
|
||||
foreach ($answers as $answer) {
|
||||
$context .= "- {$answer["question"]}: {$answer["answer"]}\n";
|
||||
}
|
||||
}
|
||||
|
||||
$system_prompt = "You are an expert editor who determines if an article request has sufficient context to write effectively.
|
||||
|
||||
Evaluate the user's request and determine which context categories are clear:
|
||||
|
||||
CATEGORIES TO EVALUATE:
|
||||
1. target_outcome - What should this content achieve? (education/marketing/sales/entertainment/brand_awareness)
|
||||
2. target_audience - Who is reading this? (demographics, role, knowledge level)
|
||||
3. tone - How should we sound? (formal/casual/technical/friendly/professional/conversational)
|
||||
4. content_depth - How comprehensive? (quick_overview/standard_guide/detailed_analysis/comprehensive)
|
||||
5. expertise_level - Reader's knowledge? (beginner/intermediate/advanced/expert)
|
||||
6. content_type - What format? (tutorial/how_to/opinion/comparison/listicle/case_study/news_analysis)
|
||||
7. pov - Whose perspective? (first_person/third_person/expert_voice/neutral)
|
||||
|
||||
For each MISSING category, generate a clarifying question using PREDEFINED OPTIONS.
|
||||
Use 'single_choice' or 'multiple_choice' types - NEVER 'open_text'.
|
||||
|
||||
QUESTION STRUCTURE:
|
||||
{
|
||||
'id': 'q1',
|
||||
'category': 'target_outcome',
|
||||
'question': 'What is the primary goal of this content?',
|
||||
'type': 'single_choice',
|
||||
'options': [
|
||||
{ 'value': 'Education - Teach something new', 'default': true },
|
||||
{ 'value': 'Marketing - Promote a product/service', 'default': false },
|
||||
{ 'value': 'Sales - Drive conversions/signups', 'default': false },
|
||||
{ 'value': 'Entertainment - Engage and entertain', 'default': false },
|
||||
{ 'value': 'Brand Awareness - Build authority/trust', 'default': false }
|
||||
]
|
||||
}
|
||||
|
||||
CONFIDENCE CALCULATION:
|
||||
- Start at 100% (1.0)
|
||||
- Subtract 15% for each missing required category
|
||||
- If confidence < {$threshold}, generate questions for ALL missing categories
|
||||
|
||||
Return ONLY valid JSON with this structure:
|
||||
{
|
||||
'is_clear': true/false,
|
||||
'confidence': 0.0-1.0,
|
||||
'missing_categories': ['category1', 'category2'],
|
||||
'questions': [ ... ]
|
||||
}
|
||||
|
||||
No markdown, no explanation - just JSON.";
|
||||
|
||||
$messages = [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => $system_prompt,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" =>
|
||||
"Topic: {$topic}\n\nRequired Categories: " .
|
||||
implode(", ", $required_categories) .
|
||||
"\n\nEvaluate this request and determine which context is missing.{$context}",
|
||||
],
|
||||
];
|
||||
|
||||
$response = $provider->chat(
|
||||
$messages,
|
||||
["temperature" => 0.7],
|
||||
"planning",
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
// Log error and use default questions instead of skipping.
|
||||
error_log(
|
||||
"WP Agentic Writer: Clarity check API error - " .
|
||||
$response->get_error_message(),
|
||||
);
|
||||
return $this->get_default_clarification_questions($topic);
|
||||
}
|
||||
|
||||
// Extract JSON from response.
|
||||
$content = $response["content"];
|
||||
$result = $this->sidebar->extract_json($content);
|
||||
|
||||
if (null === $result) {
|
||||
// Log parse error and use default questions instead of skipping.
|
||||
error_log("WP Agentic Writer: Failed to parse clarity check JSON");
|
||||
return $this->get_default_clarification_questions($topic);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append configuration questions to clarity quiz.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $questions Existing questions.
|
||||
* @param array $post_config Post configuration.
|
||||
* @return array Updated questions with config prompts.
|
||||
*/
|
||||
public function append_config_questions($questions, $post_config)
|
||||
{
|
||||
$detected_language = $post_config["language"] ?? "auto";
|
||||
$is_indonesian = "Indonesian" === $detected_language;
|
||||
|
||||
// Get preferred languages from settings
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$preferred_languages = array_merge(
|
||||
$settings["preferred_languages"] ?? [
|
||||
"auto",
|
||||
"English",
|
||||
"Indonesian",
|
||||
],
|
||||
$settings["custom_languages"] ?? [],
|
||||
);
|
||||
|
||||
// Build language options from site preferences
|
||||
$language_options = [];
|
||||
foreach ($preferred_languages as $lang) {
|
||||
$language_options[] = [
|
||||
"value" => $lang,
|
||||
"default" => "auto" === $lang,
|
||||
];
|
||||
}
|
||||
|
||||
// Language selection question (FIRST)
|
||||
$questions[] = [
|
||||
"id" => "config_language",
|
||||
"category" => "config",
|
||||
"question" => $is_indonesian
|
||||
? "🌍 Pilih Bahasa Artikel (Select Article Language)"
|
||||
: "🌍 Select Article Language",
|
||||
"type" => "single_choice",
|
||||
"options" => $language_options,
|
||||
];
|
||||
|
||||
// Single consolidated config question with all fields
|
||||
$questions[] = [
|
||||
"id" => "config_all",
|
||||
"category" => "config",
|
||||
"question" => $is_indonesian
|
||||
? "⚙️ Konfigurasi Artikel (Article Configuration)"
|
||||
: "⚙️ Article Configuration",
|
||||
"type" => "config_form",
|
||||
"fields" => [
|
||||
[
|
||||
"id" => "web_search",
|
||||
"label" => $is_indonesian
|
||||
? "🔍 Pencarian Web (Web Search)"
|
||||
: "🔍 Web Search",
|
||||
"description" => $is_indonesian
|
||||
? 'Aktifkan untuk data terkini (~$0.02/pencarian)'
|
||||
: 'Enable for current data (~$0.02/search)',
|
||||
"type" => "toggle",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"id" => "seo",
|
||||
"label" => $is_indonesian
|
||||
? "📊 Optimasi SEO (SEO Optimization)"
|
||||
: "📊 SEO Optimization",
|
||||
"description" => $is_indonesian
|
||||
? "Optimalkan artikel untuk mesin pencari"
|
||||
: "Optimize article for search engines",
|
||||
"type" => "toggle",
|
||||
"default" => true,
|
||||
],
|
||||
[
|
||||
"id" => "focus_keyword",
|
||||
"label" => $is_indonesian
|
||||
? "🎯 Kata Kunci Fokus (Focus Keyword)"
|
||||
: "🎯 Focus Keyword",
|
||||
"placeholder" => $is_indonesian
|
||||
? "Contoh: wordpress plugin"
|
||||
: "Example: wordpress plugin",
|
||||
"type" => "text",
|
||||
"max_length" => 100,
|
||||
"conditional" => "seo",
|
||||
"default" => $post_config["seo_focus_keyword"] ?? "",
|
||||
"description" => !empty($post_config["seo_focus_keyword"])
|
||||
? ($is_indonesian
|
||||
? "💡 Disarankan AI - edit jika perlu"
|
||||
: "💡 AI-suggested - edit if needed")
|
||||
: "",
|
||||
],
|
||||
[
|
||||
"id" => "secondary_keywords",
|
||||
"label" => $is_indonesian
|
||||
? "🔑 Kata Kunci Sekunder (Secondary Keywords)"
|
||||
: "🔑 Secondary Keywords",
|
||||
"placeholder" => $is_indonesian
|
||||
? "Pisahkan dengan koma"
|
||||
: "Comma-separated",
|
||||
"type" => "text",
|
||||
"max_length" => 200,
|
||||
"conditional" => "seo",
|
||||
"default" => $post_config["seo_secondary_keywords"] ?? "",
|
||||
"description" => !empty(
|
||||
$post_config["seo_secondary_keywords"]
|
||||
)
|
||||
? ($is_indonesian
|
||||
? "💡 Disarankan AI - edit jika perlu"
|
||||
: "💡 AI-suggested - edit if needed")
|
||||
: "",
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
return $questions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default clarification questions when AI fails.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $topic User's topic.
|
||||
* @return array Clarification result with default questions.
|
||||
*/
|
||||
public function get_default_clarification_questions($topic)
|
||||
{
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$required_categories = $settings["required_context_categories"] ?? [
|
||||
"target_outcome",
|
||||
"target_audience",
|
||||
"tone",
|
||||
"content_depth",
|
||||
"expertise_level",
|
||||
];
|
||||
|
||||
$questions = [];
|
||||
$question_id = 1;
|
||||
|
||||
$question_templates = [
|
||||
"target_outcome" => [
|
||||
"category" => "target_outcome",
|
||||
"question" => "What is the primary goal of this content?",
|
||||
"type" => "single_choice",
|
||||
"options" => [
|
||||
[
|
||||
"value" => "Education - Teach something new",
|
||||
"default" => true,
|
||||
],
|
||||
[
|
||||
"value" => "Marketing - Promote a product/service",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Sales - Drive conversions",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Entertainment - Engage readers",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Brand Awareness - Build authority",
|
||||
"default" => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
"target_audience" => [
|
||||
"category" => "target_audience",
|
||||
"question" => "Who is the primary audience for this content?",
|
||||
"type" => "single_choice",
|
||||
"options" => [
|
||||
[
|
||||
"value" => "General public / Beginners",
|
||||
"default" => true,
|
||||
],
|
||||
[
|
||||
"value" => "Professionals in the field",
|
||||
"default" => false,
|
||||
],
|
||||
["value" => "Potential customers", "default" => false],
|
||||
["value" => "Existing customers/users", "default" => false],
|
||||
["value" => "Industry peers / Experts", "default" => false],
|
||||
],
|
||||
],
|
||||
"tone" => [
|
||||
"category" => "tone",
|
||||
"question" => "What tone should this content have?",
|
||||
"type" => "single_choice",
|
||||
"options" => [
|
||||
[
|
||||
"value" => "Professional & Authoritative",
|
||||
"default" => true,
|
||||
],
|
||||
[
|
||||
"value" => "Friendly & Conversational",
|
||||
"default" => false,
|
||||
],
|
||||
["value" => "Technical & Detailed", "default" => false],
|
||||
["value" => "Casual & Entertaining", "default" => false],
|
||||
["value" => "Formal & Academic", "default" => false],
|
||||
],
|
||||
],
|
||||
"content_depth" => [
|
||||
"category" => "content_depth",
|
||||
"question" => "How comprehensive should this content be?",
|
||||
"type" => "single_choice",
|
||||
"options" => [
|
||||
[
|
||||
"value" => "Quick overview (500-800 words)",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Standard guide (800-1500 words)",
|
||||
"default" => true,
|
||||
],
|
||||
[
|
||||
"value" => "Detailed analysis (1500-2500 words)",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Comprehensive deep-dive (2500+ words)",
|
||||
"default" => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
"expertise_level" => [
|
||||
"category" => "expertise_level",
|
||||
"question" => 'What is the target audience\'s expertise level?',
|
||||
"type" => "single_choice",
|
||||
"options" => [
|
||||
[
|
||||
"value" => "Beginner - No prior knowledge",
|
||||
"default" => true,
|
||||
],
|
||||
[
|
||||
"value" => "Intermediate - Basic understanding",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Advanced - Deep technical knowledge",
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Expert - Industry professional",
|
||||
"default" => false,
|
||||
],
|
||||
],
|
||||
],
|
||||
"content_type" => [
|
||||
"category" => "content_type",
|
||||
"question" => "What type of content works best for this topic?",
|
||||
"type" => "single_choice",
|
||||
"options" => [
|
||||
["value" => "Tutorial / How-to guide", "default" => true],
|
||||
["value" => "Opinion / Commentary", "default" => false],
|
||||
["value" => "Comparison / Review", "default" => false],
|
||||
["value" => "Listicle / Tips", "default" => false],
|
||||
["value" => "Case study", "default" => false],
|
||||
["value" => "News analysis", "default" => false],
|
||||
],
|
||||
],
|
||||
"pov" => [
|
||||
"category" => "pov",
|
||||
"question" => "From what perspective should this be written?",
|
||||
"type" => "single_choice",
|
||||
"options" => [
|
||||
[
|
||||
"value" => 'Third person (objective, "it", "they")',
|
||||
"default" => true,
|
||||
],
|
||||
[
|
||||
"value" => 'First person (personal, "I", "my")',
|
||||
"default" => false,
|
||||
],
|
||||
[
|
||||
"value" => "Expert voice (authoritative, experienced)",
|
||||
"default" => false,
|
||||
],
|
||||
["value" => "Neutral / Unbiased", "default" => false],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($required_categories as $category) {
|
||||
if (isset($question_templates[$category])) {
|
||||
$q = $question_templates[$category];
|
||||
$q["id"] = "q" . $question_id++;
|
||||
$questions[] = $q;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
"is_clear" => false,
|
||||
"confidence" => 0.0,
|
||||
"missing_categories" => $required_categories,
|
||||
"questions" => $questions,
|
||||
];
|
||||
}
|
||||
}
|
||||
104
includes/class-controller-config.php
Normal file
104
includes/class-controller-config.php
Normal file
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
/**
|
||||
* Config REST Controller
|
||||
*
|
||||
* Handles post configuration operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Config
|
||||
*
|
||||
* REST controller for post configuration operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Config {
|
||||
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct( $sidebar ) {
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get post config request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_get_post_config( $request ) {
|
||||
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
|
||||
if ( $post_id <= 0 ) {
|
||||
return new WP_Error(
|
||||
'invalid_post',
|
||||
__( 'Invalid post ID.', 'wp-agentic-writer' ),
|
||||
[ '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 ],
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response( $this->sidebar->get_post_config( $post_id ), 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update post config request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_update_post_config( $request ) {
|
||||
$post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0;
|
||||
if ( $post_id <= 0 ) {
|
||||
return new WP_Error(
|
||||
'invalid_post',
|
||||
__( 'Invalid post ID.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 400 ],
|
||||
);
|
||||
}
|
||||
|
||||
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 ],
|
||||
);
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
$config = $this->sidebar->sanitize_post_config( $params['postConfig'] ?? [] );
|
||||
update_post_meta( $post_id, '_wpaw_post_config', $config );
|
||||
|
||||
// MEMANTO: Store config preference.
|
||||
do_action( 'wpaw_memanto_config_saved', $post_id, $config );
|
||||
|
||||
return new WP_REST_Response( $config, 200 );
|
||||
}
|
||||
}
|
||||
502
includes/class-controller-conversation.php
Normal file
502
includes/class-controller-conversation.php
Normal file
@@ -0,0 +1,502 @@
|
||||
<?php
|
||||
/**
|
||||
* Conversation REST Controller
|
||||
*
|
||||
* Handles conversation CRUD operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Conversation
|
||||
*
|
||||
* REST controller for conversation operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Conversation {
|
||||
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct( $sidebar ) {
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get conversations request.
|
||||
*
|
||||
* Lists all conversations for the current user, optionally filtered by post.
|
||||
*
|
||||
* @since 0.1.4
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_get_conversations( $request ) {
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
$status = sanitize_text_field( $request->get_param( 'status' ) ?: 'active' );
|
||||
$limit = (int) $request->get_param( 'limit' ) ?: 20;
|
||||
$post_id = (int) $request->get_param( 'post_id' ) ?: 0;
|
||||
|
||||
// If post_id is specified, check authorization before returning session.
|
||||
if ( $post_id > 0 ) {
|
||||
// Authorization: User must be able to edit this 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 ],
|
||||
);
|
||||
}
|
||||
|
||||
$sessions = $manager->get_sessions_for_post( $post_id );
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
'sessions' => $sessions,
|
||||
'count' => count( $sessions ),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
if ( $request->get_param( 'uncompleted' ) ) {
|
||||
$sessions = $manager->get_uncompleted_sessions( $limit );
|
||||
} else {
|
||||
$sessions = $manager->get_user_sessions( $status, $limit );
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
'sessions' => $sessions,
|
||||
'count' => count( $sessions ),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle create conversation request.
|
||||
*
|
||||
* @since 0.1.4
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_create_conversation( $request ) {
|
||||
$params = $request->get_json_params();
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
$post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0;
|
||||
$focus_keyword = isset( $params['focus_keyword'] )
|
||||
? sanitize_text_field( $params['focus_keyword'] )
|
||||
: '';
|
||||
$title = isset( $params['title'] )
|
||||
? sanitize_text_field( $params['title'] )
|
||||
: '';
|
||||
|
||||
// Authorization: If linking to a post, check edit permission.
|
||||
if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have permission to create a session for this post.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
if ( '' === $title && $post_id > 0 ) {
|
||||
$post = get_post( $post_id );
|
||||
$base_title = $post ? sanitize_text_field( $post->post_title ) : '';
|
||||
if ( '' === $base_title ) {
|
||||
$base_title = 'Conversation';
|
||||
}
|
||||
$title = sprintf( '%s - %s', $base_title, current_time( 'Y-m-d H:i' ) );
|
||||
}
|
||||
|
||||
$session_id = $manager->create_session(
|
||||
[
|
||||
'post_id' => $post_id,
|
||||
'focus_keyword' => $focus_keyword,
|
||||
'title' => $title,
|
||||
]
|
||||
);
|
||||
|
||||
if ( is_wp_error( $session_id ) ) {
|
||||
return $session_id;
|
||||
}
|
||||
|
||||
$session = $manager->get_session( $session_id );
|
||||
|
||||
// MEMANTO: New session created.
|
||||
do_action(
|
||||
'wpaw_memanto_session_start',
|
||||
$session_id,
|
||||
$post_id,
|
||||
get_current_user_id(),
|
||||
);
|
||||
|
||||
return new WP_REST_Response( $session, 201 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get single conversation request.
|
||||
*
|
||||
* @since 0.1.4
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_get_conversation( $request ) {
|
||||
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
// Check authorization.
|
||||
if ( ! $manager->current_user_can_access( $session_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have permission to access this conversation.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
$session = $manager->get_session( $session_id );
|
||||
|
||||
if ( ! $session ) {
|
||||
return new WP_Error(
|
||||
'not_found',
|
||||
__( 'Conversation not found.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 404 ],
|
||||
);
|
||||
}
|
||||
|
||||
$session = $this->hydrate_session_plan_messages( $session );
|
||||
|
||||
return new WP_REST_Response( $session, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_update_conversation( $request ) {
|
||||
$params = $request->get_json_params();
|
||||
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
// Check authorization.
|
||||
if ( ! $manager->current_user_can_access( $session_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have permission to modify this conversation.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
$session = $manager->get_session( $session_id );
|
||||
|
||||
if ( ! $session ) {
|
||||
return new WP_Error(
|
||||
'not_found',
|
||||
__( 'Conversation not found.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 404 ],
|
||||
);
|
||||
}
|
||||
|
||||
// Update fields.
|
||||
if ( isset( $params['title'] ) ) {
|
||||
$manager->update_title( $session_id, $params['title'] );
|
||||
}
|
||||
|
||||
if ( isset( $params['focus_keyword'] ) ) {
|
||||
$manager->update_focus_keyword(
|
||||
$session_id,
|
||||
$params['focus_keyword'],
|
||||
);
|
||||
}
|
||||
|
||||
if ( isset( $params['status'] ) ) {
|
||||
if ( 'completed' === $params['status'] ) {
|
||||
$manager->mark_completed( $session_id );
|
||||
|
||||
// MEMANTO: Session completed.
|
||||
$post_id = $session['post_id'] ?? 0;
|
||||
do_action(
|
||||
'wpaw_memanto_session_end',
|
||||
$session_id,
|
||||
(int) $post_id,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$updated_session = $manager->get_session( $session_id );
|
||||
|
||||
return new WP_REST_Response( $updated_session, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delete conversation request.
|
||||
*
|
||||
* @since 0.1.4
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_delete_conversation( $request ) {
|
||||
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
// Check authorization.
|
||||
if ( ! $manager->current_user_can_access( $session_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have permission to delete this conversation.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
$result = $manager->delete_session( $session_id );
|
||||
|
||||
if ( ! $result ) {
|
||||
return new WP_Error(
|
||||
'delete_failed',
|
||||
__( 'Failed to delete conversation.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 500 ],
|
||||
);
|
||||
}
|
||||
|
||||
// MEMANTO: Session deleted — treat as session end.
|
||||
$session = $manager->get_session_unchecked( $session_id );
|
||||
$post_id = $session ? $session['post_id'] ?? 0 : 0;
|
||||
do_action( 'wpaw_memanto_session_end', $session_id, (int) $post_id );
|
||||
|
||||
return new WP_REST_Response( [ 'deleted' => true ], 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle update conversation messages request.
|
||||
*
|
||||
* @since 0.1.4
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_update_conversation_messages( $request ) {
|
||||
$params = $request->get_json_params();
|
||||
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
// Check authorization.
|
||||
if ( ! $manager->current_user_can_access( $session_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have permission to modify this conversation.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
$session = $manager->get_session( $session_id );
|
||||
|
||||
if ( ! $session ) {
|
||||
return new WP_Error(
|
||||
'not_found',
|
||||
__( 'Conversation not found.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 404 ],
|
||||
);
|
||||
}
|
||||
|
||||
$messages = isset( $params['messages'] ) ? $params['messages'] : [];
|
||||
|
||||
if ( ! is_array( $messages ) ) {
|
||||
return new WP_Error(
|
||||
'invalid_messages',
|
||||
__( 'Messages must be an array.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 400 ],
|
||||
);
|
||||
}
|
||||
|
||||
// Safety: refuse to overwrite existing messages with an empty array.
|
||||
// This prevents race conditions during session switches from wiping
|
||||
// conversation history. Intentional clears go through clear-context.
|
||||
$existing_count = count( $session['messages'] ?? [] );
|
||||
if ( empty( $messages ) && $existing_count > 0 ) {
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
'updated' => false,
|
||||
'message_count' => $existing_count,
|
||||
'skipped' => 'empty_overwrite_blocked',
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
$updated = $manager->update_messages( $session_id, $messages );
|
||||
if ( ! $updated ) {
|
||||
return new WP_Error(
|
||||
'message_update_failed',
|
||||
__(
|
||||
'Failed to update conversation messages.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 500 ],
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[ 'updated' => true, 'message_count' => count( $messages ) ],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle link conversation to post request.
|
||||
*
|
||||
* @since 0.1.4
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_link_conversation_to_post( $request ) {
|
||||
$params = $request->get_json_params();
|
||||
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
// First verify user has access to this session (before linking to post).
|
||||
if ( ! $manager->current_user_can_access( $session_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have access to this conversation.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
$session = $manager->get_session( $session_id );
|
||||
|
||||
if ( ! $session ) {
|
||||
return new WP_Error(
|
||||
'not_found',
|
||||
__( 'Conversation not found.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 404 ],
|
||||
);
|
||||
}
|
||||
|
||||
$post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0;
|
||||
|
||||
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 ],
|
||||
);
|
||||
}
|
||||
|
||||
$manager->link_to_post( $session_id, $post_id );
|
||||
|
||||
$updated_session = $manager->get_session( $session_id );
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
'linked' => true,
|
||||
'post_id' => $post_id,
|
||||
'session' => $updated_session,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
}
|
||||
63
includes/class-controller-cost.php
Normal file
63
includes/class-controller-cost.php
Normal file
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
/**
|
||||
* Cost REST Controller
|
||||
*
|
||||
* Handles cost tracking operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Cost
|
||||
*
|
||||
* REST controller for cost tracking operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Cost {
|
||||
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct( $sidebar ) {
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get cost tracking request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_get_cost_tracking( $request ) {
|
||||
$post_id = $request->get_param( 'post_id' );
|
||||
|
||||
// Check post-specific 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 ],
|
||||
);
|
||||
}
|
||||
|
||||
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
|
||||
$data = $cost_tracker->get_frontend_data( $post_id );
|
||||
|
||||
return new WP_REST_Response( $data, 200 );
|
||||
}
|
||||
}
|
||||
165
includes/class-controller-image.php
Normal file
165
includes/class-controller-image.php
Normal file
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
/**
|
||||
* Image REST Controller
|
||||
*
|
||||
* Handles image generation and recommendation operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Image
|
||||
*
|
||||
* REST controller for image operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Image
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get image recommendations request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @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->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],
|
||||
);
|
||||
}
|
||||
|
||||
$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->sidebar->get_post_config($post_id);
|
||||
if (!empty($post_config["include_images"])) {
|
||||
$images = $this->sidebar->sync_image_block_recommendations(
|
||||
$post_id,
|
||||
$post,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response(["images" => $images], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generate image request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @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->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],
|
||||
);
|
||||
}
|
||||
|
||||
$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.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @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->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],
|
||||
);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
201
includes/class-controller-memanto.php
Normal file
201
includes/class-controller-memanto.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
/**
|
||||
* Memanto REST Controller
|
||||
*
|
||||
* Handles MEMANTO memory and preferences operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Memanto
|
||||
*
|
||||
* REST controller for MEMANTO memory operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Memanto
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MEMANTO status check.
|
||||
*
|
||||
* Returns current MEMANTO connection status, health, and configuration
|
||||
* state for the frontend sidebar indicator.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response Status response.
|
||||
*/
|
||||
public function handle_memanto_status($request)
|
||||
{
|
||||
$client = WP_Agentic_Writer_Memanto_Client::get_instance();
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
|
||||
$configured = $client->is_configured();
|
||||
$enabled = $client->is_enabled();
|
||||
$healthy = $configured && $enabled ? $client->is_healthy() : false;
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"configured" => $configured,
|
||||
"enabled" => $enabled,
|
||||
"healthy" => $healthy,
|
||||
"active" => $configured && $enabled && $healthy,
|
||||
"url_set" => !empty($settings["memanto_url"]),
|
||||
"key_set" => !empty($settings["memanto_moorcheh_key"]),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MEMANTO recall request.
|
||||
*
|
||||
* Returns recent memories for a post and user preferences.
|
||||
* Used by the frontend to show "Restored from memory" indicator
|
||||
* and to carry preferences across posts.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_memanto_recall($request)
|
||||
{
|
||||
$post_id = (int) ($request->get_param("post_id") ?? 0);
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
$enhancer = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance();
|
||||
$memories = $enhancer->recall_for_context(
|
||||
$post_id,
|
||||
$user_id,
|
||||
"", // No current message for restore — just recent + preferences.
|
||||
);
|
||||
|
||||
// Separate preferences from other memories for frontend display.
|
||||
$preferences = [];
|
||||
$other = [];
|
||||
foreach ($memories as $memory) {
|
||||
if (($memory["type"] ?? "") === "preference") {
|
||||
$preferences[] = $memory;
|
||||
} else {
|
||||
$other[] = $memory;
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"memories" => $other,
|
||||
"preferences" => $preferences,
|
||||
"count" => count($memories),
|
||||
"post_id" => $post_id,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MEMANTO session restore.
|
||||
*
|
||||
* Called when the post editor opens to restore prior session state.
|
||||
* Returns recent memories + preferences + a restore summary.
|
||||
*
|
||||
* @since 0.4.0
|
||||
* @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)
|
||||
{
|
||||
$post_id = (int) ($request->get_param("post_id") ?? 0);
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
$enhancer = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance();
|
||||
$payload = $enhancer->restore_session($post_id, $user_id);
|
||||
|
||||
// Build the restored system message for AI context.
|
||||
$system_message = "";
|
||||
if (!empty($payload["restored"])) {
|
||||
$system_message = $enhancer->build_session_restore_message(
|
||||
$payload,
|
||||
);
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"restored" => $payload["restored"],
|
||||
"memories" => $payload["memories"],
|
||||
"preferences" => $payload["preferences"],
|
||||
"summary" => $payload["summary"],
|
||||
"system_message" => $system_message,
|
||||
"memory_count" => count($payload["memories"]),
|
||||
"preference_count" => count($payload["preferences"]),
|
||||
"post_id" => $post_id,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle MEMANTO user preferences recall.
|
||||
*
|
||||
* Returns extracted preference config for new-post config carry-over.
|
||||
*
|
||||
* @since 0.4.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response Preference payload.
|
||||
*/
|
||||
public function handle_memanto_preferences($request)
|
||||
{
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
$enhancer = WP_Agentic_Writer_Memanto_Context_Enhancer::get_instance();
|
||||
$result = $enhancer->get_user_preferences_for_new_post($user_id);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"restored" => $result["restored"],
|
||||
"config" => $result["config"],
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
}
|
||||
78
includes/class-controller-models.php
Normal file
78
includes/class-controller-models.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
/**
|
||||
* Models REST Controller
|
||||
*
|
||||
* Handles model listing and refresh operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Models
|
||||
*
|
||||
* REST controller for model operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Models {
|
||||
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct( $sidebar ) {
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get models request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_get_models() {
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$models = $provider->get_cached_models();
|
||||
|
||||
if ( is_wp_error( $models ) ) {
|
||||
return $models;
|
||||
}
|
||||
|
||||
return new WP_REST_Response( $models, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refresh models request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_refresh_models() {
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$models = $provider->fetch_and_cache_models( true );
|
||||
|
||||
if ( is_wp_error( $models ) ) {
|
||||
return $models;
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
'models' => $models,
|
||||
'message' => __(
|
||||
'Models refreshed successfully.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
}
|
||||
589
includes/class-controller-planning.php
Normal file
589
includes/class-controller-planning.php
Normal file
@@ -0,0 +1,589 @@
|
||||
<?php
|
||||
/**
|
||||
* Planning REST Controller
|
||||
*
|
||||
* Handles article planning operations including outline generation and revision.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Planning
|
||||
*
|
||||
* REST controller for planning operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Planning
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generate plan request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_generate_plan($request)
|
||||
{
|
||||
// Check rate limit.
|
||||
$rate_limit = WPAW_Rate_Limiter::check("generate_plan");
|
||||
if (is_wp_error($rate_limit)) {
|
||||
return $rate_limit;
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
$topic = $params["topic"] ?? "";
|
||||
$context = $params["context"] ?? "";
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$session_id = $this->sidebar->resolve_or_create_session_id(
|
||||
$params["sessionId"] ?? "",
|
||||
$post_id,
|
||||
);
|
||||
$auto_execute = $params["autoExecute"] ?? false;
|
||||
$stream = $params["stream"] ?? false;
|
||||
$chat_history = $params["chatHistory"] ?? [];
|
||||
$post_config = $this->sidebar->resolve_post_config_from_request(
|
||||
$params,
|
||||
$post_id,
|
||||
);
|
||||
$article_length =
|
||||
$post_config["article_length"] ??
|
||||
($params["articleLength"] ?? "medium");
|
||||
$clarification_answers = $params["clarificationAnswers"] ?? [];
|
||||
$detected_language = $params["detectedLanguage"] ?? "auto";
|
||||
$clarified_language = $this->sidebar->resolve_language_from_clarification_answers(
|
||||
$clarification_answers,
|
||||
);
|
||||
$effective_language = $this->sidebar->resolve_language_preference(
|
||||
$post_config,
|
||||
$clarified_language ?: $detected_language,
|
||||
);
|
||||
$post_config_context = $this->sidebar->build_post_config_context(
|
||||
$post_config,
|
||||
);
|
||||
$web_search_options = $this->sidebar->get_web_search_options(
|
||||
$post_config,
|
||||
);
|
||||
|
||||
if (empty($topic)) {
|
||||
return new WP_Error(
|
||||
"no_topic",
|
||||
__("Topic is required.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
// 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 edit this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
|
||||
$context_package = $context_builder->build_for_task(
|
||||
"planning",
|
||||
$session_id,
|
||||
$post_id,
|
||||
array_merge($params, [
|
||||
"chatHistory" => $chat_history,
|
||||
"postConfig" => $post_config,
|
||||
]),
|
||||
);
|
||||
|
||||
// If streaming is requested, delegate to sidebar streaming method.
|
||||
if ($stream) {
|
||||
return $this->sidebar->stream_generate_plan(
|
||||
$topic,
|
||||
$context,
|
||||
$post_id,
|
||||
$auto_execute,
|
||||
$article_length,
|
||||
$clarification_answers,
|
||||
$effective_language,
|
||||
$post_config,
|
||||
$chat_history,
|
||||
$session_id,
|
||||
);
|
||||
}
|
||||
|
||||
// Get provider for planning task.
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
||||
"planning",
|
||||
);
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
// Build prompt for plan generation.
|
||||
$plan_language_instruction = $this->sidebar->build_language_instruction(
|
||||
$effective_language,
|
||||
"article plan (title, section headings, descriptions)",
|
||||
);
|
||||
$system_prompt = "You are an Information Architect and SEO/GEO Strategist. Your task is to outline a high-information-density article based on the user's topic and context.
|
||||
|
||||
ANTI-ROBOT RULES:
|
||||
- Never use generic intros or 'throat-clearing' fluff.
|
||||
- Avoid academic, pompous, or 'expert' posturing.
|
||||
- Headings must provide direct value or ask specific questions the article will answer.
|
||||
|
||||
GEO/SEO STRATEGY:
|
||||
- Design the outline for Generative Engine Optimization (GEO): sections must flow logically to answer the user's core intent comprehensively.
|
||||
- Suggest strategic use of tables, bullet points, and Q&A formats where they maximize information density.
|
||||
- Incorporate secondary entities and related concepts naturally to show topical depth.
|
||||
|
||||
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.
|
||||
{$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.";
|
||||
|
||||
$messages = [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => $system_prompt,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => "Topic: {$topic}\n\nContext:\n{$context_package["working_context"]}\n\n{$context_package["research_context"]}",
|
||||
],
|
||||
];
|
||||
|
||||
// Generate plan.
|
||||
$this->sidebar->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",
|
||||
);
|
||||
|
||||
// Debug: log the provider type and response
|
||||
$provider_class = get_class($provider);
|
||||
wpaw_debug_log("Plan generation using provider: " . $provider_class);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
wpaw_debug_log(
|
||||
"Plan generation error: " . $response->get_error_message(),
|
||||
);
|
||||
return new WP_Error(
|
||||
"plan_generation_error",
|
||||
$response->get_error_message(),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
// Extract JSON from response.
|
||||
$content = $response["content"] ?? "";
|
||||
$plan_json = WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response(
|
||||
$content,
|
||||
$topic,
|
||||
);
|
||||
|
||||
// Debug: log the raw response
|
||||
wpaw_debug_log(
|
||||
"Plan generation raw response length: " . strlen($content),
|
||||
);
|
||||
|
||||
if (empty(trim((string) $content))) {
|
||||
$model_used = $response["model"] ?? "unknown";
|
||||
return new WP_Error(
|
||||
"empty_response",
|
||||
sprintf(
|
||||
__(
|
||||
"The AI model (%s) returned an empty response. Try a different planning model or simplify your topic.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
$model_used,
|
||||
),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
if (null === $plan_json) {
|
||||
wpaw_debug_log(
|
||||
"extract_plan_from_response returned null. Content preview: " .
|
||||
substr($content, 0, 500),
|
||||
);
|
||||
return new WP_Error(
|
||||
"invalid_json",
|
||||
sprintf(
|
||||
/* translators: %s: model output preview */
|
||||
__(
|
||||
'The AI responded but the outline couldn\'t be parsed as JSON. Try again — this is usually a one-time formatting issue. Preview: %s',
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
$this->sidebar->build_model_output_preview($content),
|
||||
),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
$plan_json = $this->sidebar->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" => WP_Agentic_Writer_Sidebar_Helpers::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->sidebar->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 = WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
||||
$plan_json,
|
||||
);
|
||||
$this->sidebar->update_post_memory($post_id, [
|
||||
"summary" => $summary,
|
||||
"last_prompt" => $topic,
|
||||
"last_intent" => "generate",
|
||||
]);
|
||||
}
|
||||
|
||||
// Track cost with provider metadata.
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$response["model"] ?? "",
|
||||
"planning",
|
||||
$response["input_tokens"] ?? 0,
|
||||
$response["output_tokens"] ?? 0,
|
||||
$response["cost"] ?? 0,
|
||||
$provider_result,
|
||||
$session_id,
|
||||
"success",
|
||||
);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"plan" => $plan_json,
|
||||
"cost" => $response["cost"] ?? 0,
|
||||
"web_search_results" => $response["web_search_results"] ?? [],
|
||||
"context_audit" => $context_package["audit"] ?? [],
|
||||
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
||||
$provider_result,
|
||||
$response["model"] ?? "",
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle revise plan request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_revise_plan($request)
|
||||
{
|
||||
$params = $request->get_json_params();
|
||||
$instruction = $params["instruction"] ?? "";
|
||||
$plan = $params["plan"] ?? [];
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$session_id = $this->sidebar->resolve_or_create_session_id(
|
||||
$params["sessionId"] ?? "",
|
||||
$post_id,
|
||||
);
|
||||
|
||||
if (empty($instruction)) {
|
||||
return new WP_Error(
|
||||
"no_instruction",
|
||||
__("Instruction is required.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
if (empty($plan) || !is_array($plan)) {
|
||||
return new WP_Error(
|
||||
"no_plan",
|
||||
__("Plan is required to revise.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
// Check post permission BEFORE reading post data.
|
||||
if ($post_id > 0 && !$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],
|
||||
);
|
||||
}
|
||||
|
||||
// Only read post config/meta after permission check.
|
||||
$post_config = $this->sidebar->resolve_post_config_from_request(
|
||||
$params,
|
||||
$post_id,
|
||||
);
|
||||
$post_config_context = $this->sidebar->build_post_config_context(
|
||||
$post_config,
|
||||
);
|
||||
$effective_language = $this->sidebar->resolve_language_preference(
|
||||
$post_config,
|
||||
get_post_meta($post_id, "_wpaw_detected_language", true),
|
||||
);
|
||||
$plan_language_instruction = $this->sidebar->build_language_instruction(
|
||||
$effective_language,
|
||||
"article plan (title, section headings, descriptions)",
|
||||
);
|
||||
$web_search_options = $this->sidebar->get_web_search_options(
|
||||
$post_config,
|
||||
);
|
||||
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
||||
"planning",
|
||||
);
|
||||
$provider = $provider_result->provider;
|
||||
$memory_context = WP_Agentic_Writer_Sidebar_Helpers::get_post_memory_context(
|
||||
$post_id,
|
||||
);
|
||||
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
|
||||
$context_package = $context_builder->build_for_task(
|
||||
"plan_revision",
|
||||
$session_id,
|
||||
$post_id,
|
||||
array_merge($params, [
|
||||
"plan" => $plan,
|
||||
"postConfig" => $post_config,
|
||||
]),
|
||||
);
|
||||
|
||||
$system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction.
|
||||
|
||||
{$plan_language_instruction}
|
||||
{$post_config_context}
|
||||
{$memory_context}
|
||||
|
||||
IMPORTANT:
|
||||
- Return ONLY valid raw JSON matching the plan schema below.
|
||||
- Do not wrap in markdown code fences or add explanatory text.
|
||||
- Keep the same structure: title, meta, sections with id/status/type/heading/content.
|
||||
|
||||
Schema:
|
||||
{
|
||||
\"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\": \"Description\"]}
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
$messages = [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => $system_prompt,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => "Revision instruction: {$instruction}\n\nExisting outline:\n" .
|
||||
wp_json_encode($plan) .
|
||||
"\n\nContext:\n{$context_package["working_context"]}",
|
||||
],
|
||||
];
|
||||
|
||||
// Generate revised plan.
|
||||
$this->sidebar->maybe_inject_brave_search(
|
||||
$messages,
|
||||
$provider,
|
||||
$web_search_options,
|
||||
);
|
||||
$response = $provider->chat(
|
||||
$messages,
|
||||
array_merge(["temperature" => 0.6], $web_search_options),
|
||||
"planning",
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return new WP_Error(
|
||||
"plan_revision_error",
|
||||
$response->get_error_message(),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
$plan_json = WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response(
|
||||
$response["content"],
|
||||
$instruction,
|
||||
$plan,
|
||||
);
|
||||
if (null === $plan_json) {
|
||||
return new WP_Error(
|
||||
"plan_revision_invalid",
|
||||
__("Failed to generate valid plan JSON.", "wp-agentic-writer"),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
$plan_json = $this->sidebar->ensure_plan_sections_with_tasks(
|
||||
$plan_json,
|
||||
$plan,
|
||||
);
|
||||
|
||||
// MEMANTO: Remember plan revision (implies previous plan was rejected).
|
||||
do_action("wpaw_memanto_plan_rejected", $post_id, $instruction);
|
||||
|
||||
if ($post_id > 0) {
|
||||
update_post_meta($post_id, "_wpaw_plan", $plan_json);
|
||||
if (!empty($effective_language)) {
|
||||
update_post_meta(
|
||||
$post_id,
|
||||
"_wpaw_detected_language",
|
||||
$effective_language,
|
||||
);
|
||||
}
|
||||
$summary = WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
||||
$plan_json,
|
||||
);
|
||||
$this->sidebar->update_post_memory($post_id, [
|
||||
"summary" => $summary,
|
||||
"last_prompt" => $instruction,
|
||||
"last_intent" => "plan",
|
||||
]);
|
||||
}
|
||||
|
||||
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($instruction),
|
||||
"plan" => $plan,
|
||||
],
|
||||
10,
|
||||
);
|
||||
$context_service->update_session_context($session_id, [
|
||||
"working_summary" => [
|
||||
"text" => WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
||||
$plan_json,
|
||||
),
|
||||
"updated_at" => current_time("c"),
|
||||
"source_message_count" => 0,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
// Track cost with provider metadata.
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$response["model"] ?? "",
|
||||
"planning",
|
||||
$response["input_tokens"] ?? 0,
|
||||
$response["output_tokens"] ?? 0,
|
||||
$response["cost"] ?? 0,
|
||||
$provider_result,
|
||||
$session_id,
|
||||
"success",
|
||||
);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"plan" => $plan_json,
|
||||
"cost" => $response["cost"] ?? 0,
|
||||
"context_audit" => $context_package["audit"] ?? [],
|
||||
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
||||
$provider_result,
|
||||
$response["model"] ?? "",
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
}
|
||||
428
includes/class-controller-refinement.php
Normal file
428
includes/class-controller-refinement.php
Normal file
@@ -0,0 +1,428 @@
|
||||
<?php
|
||||
/**
|
||||
* Refinement REST Controller
|
||||
*
|
||||
* Handles content refinement operations including block refinement and regeneration.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Refinement
|
||||
*
|
||||
* REST controller for refinement operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Refinement
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle block refine request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error|void Response or streaming.
|
||||
*/
|
||||
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->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],
|
||||
);
|
||||
}
|
||||
|
||||
// Only read post config after permission check.
|
||||
$post_config = $this->sidebar->resolve_post_config_from_request(
|
||||
$params,
|
||||
$post_id,
|
||||
);
|
||||
|
||||
// If streaming is requested, use streaming response.
|
||||
if ($stream) {
|
||||
return $this->sidebar->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->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$response["model"] ?? "",
|
||||
"block_refinement",
|
||||
$response["input_tokens"] ?? 0,
|
||||
$response["output_tokens"] ?? 0,
|
||||
$response["cost"] ?? 0,
|
||||
$provider_result,
|
||||
"",
|
||||
"success",
|
||||
);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"blocks" => $blocks,
|
||||
"blockId" => $block_id,
|
||||
"cost" => $response["cost"] ?? 0,
|
||||
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
||||
$provider_result,
|
||||
$response["model"] ?? "",
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle regenerate block request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_regenerate_block($request)
|
||||
{
|
||||
// Check rate limit.
|
||||
$rate_limit = WPAW_Rate_Limiter::check("refine");
|
||||
if (is_wp_error($rate_limit)) {
|
||||
return $rate_limit;
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
$block_content = $params["blockContent"] ?? "";
|
||||
$context = $params["context"] ?? "";
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
|
||||
if (empty($block_content)) {
|
||||
return new WP_Error(
|
||||
"no_content",
|
||||
__("Block content is required.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
// 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 edit this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
// Get provider for writing task.
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
||||
"writing",
|
||||
);
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
$messages = [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" =>
|
||||
"You are an expert technical writer. Rewrite the provided content to improve it while maintaining the same meaning and key information.",
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => "Context: {$context}\n\nOriginal content:\n\n{$block_content}\n\nPlease rewrite this content.",
|
||||
],
|
||||
];
|
||||
|
||||
$response = $provider->chat(
|
||||
$messages,
|
||||
["temperature" => 0.8],
|
||||
"execution",
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
// Track failed attempt for observability.
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
WPAW_Model_Registry::get_default_model("writing"),
|
||||
"regeneration",
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
$provider_result,
|
||||
"",
|
||||
"error",
|
||||
);
|
||||
return new WP_Error(
|
||||
"regeneration_error",
|
||||
$response->get_error_message(),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
// Track cost (always track for debugging).
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$response["model"] ?? "",
|
||||
"regeneration",
|
||||
$response["input_tokens"] ?? 0,
|
||||
$response["output_tokens"] ?? 0,
|
||||
$response["cost"] ?? 0,
|
||||
$provider_result,
|
||||
"",
|
||||
"success",
|
||||
);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"content" => $response["content"],
|
||||
"cost" => $response["cost"] ?? 0,
|
||||
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
||||
$provider_result,
|
||||
$response["model"] ?? "",
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle refine from chat request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @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->sidebar->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->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],
|
||||
);
|
||||
}
|
||||
|
||||
// Only read post config after permission check.
|
||||
$post_config = $this->sidebar->resolve_post_config_from_request(
|
||||
$params,
|
||||
$post_id,
|
||||
);
|
||||
|
||||
// Stream refinement for each mentioned block
|
||||
$this->sidebar->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();
|
||||
}
|
||||
}
|
||||
1541
includes/class-controller-seo.php
Normal file
1541
includes/class-controller-seo.php
Normal file
File diff suppressed because it is too large
Load Diff
114
includes/class-controller-session.php
Normal file
114
includes/class-controller-session.php
Normal file
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
/**
|
||||
* Session REST Controller
|
||||
*
|
||||
* Handles session lock operations for multi-tab safety.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Session
|
||||
*
|
||||
* REST controller for session operations.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Session {
|
||||
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct( $sidebar ) {
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session lock acquire/refresh (heartbeat).
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_session_lock( $request ) {
|
||||
$params = $request->get_json_params();
|
||||
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
|
||||
$tab_id = sanitize_text_field( $params['tab_id'] ?? '' );
|
||||
$force = ! empty( $params['force'] );
|
||||
|
||||
if ( ! $tab_id ) {
|
||||
return new WP_Error(
|
||||
'missing_tab_id',
|
||||
__( 'tab_id is required.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 400 ],
|
||||
);
|
||||
}
|
||||
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
if ( ! $manager->current_user_can_access( $session_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have permission to access this conversation.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
// Force mode: use a 0 window so any existing lock is treated as expired.
|
||||
$window = $force ? 0 : 60;
|
||||
$result = $manager->acquire_lock( $session_id, $tab_id, $window );
|
||||
|
||||
return new WP_REST_Response( $result, 200 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session lock release.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error
|
||||
*/
|
||||
public function handle_session_unlock( $request ) {
|
||||
$params = $request->get_json_params();
|
||||
$session_id = sanitize_text_field( $request->get_param( 'session_id' ) );
|
||||
$tab_id = sanitize_text_field( $params['tab_id'] ?? '' );
|
||||
|
||||
if ( ! $tab_id ) {
|
||||
return new WP_Error(
|
||||
'missing_tab_id',
|
||||
__( 'tab_id is required.', 'wp-agentic-writer' ),
|
||||
[ 'status' => 400 ],
|
||||
);
|
||||
}
|
||||
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
if ( ! $manager->current_user_can_access( $session_id ) ) {
|
||||
return new WP_Error(
|
||||
'forbidden',
|
||||
__(
|
||||
'You do not have permission to access this conversation.',
|
||||
'wp-agentic-writer',
|
||||
),
|
||||
[ 'status' => 403 ],
|
||||
);
|
||||
}
|
||||
|
||||
$released = $manager->release_lock( $session_id, $tab_id );
|
||||
|
||||
return new WP_REST_Response( [ 'released' => $released ], 200 );
|
||||
}
|
||||
}
|
||||
616
includes/class-controller-writing.php
Normal file
616
includes/class-controller-writing.php
Normal file
@@ -0,0 +1,616 @@
|
||||
<?php
|
||||
/**
|
||||
* Writing REST Controller
|
||||
*
|
||||
* Handles article writing and formatting operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Writing
|
||||
*
|
||||
* REST controller for writing operations including article execution and block formatting.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Writing
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execute article request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_execute_article($request)
|
||||
{
|
||||
// Check rate limit.
|
||||
$rate_limit = WPAW_Rate_Limiter::check("execute_article");
|
||||
if (is_wp_error($rate_limit)) {
|
||||
return $rate_limit;
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$session_id = $this->sidebar->resolve_or_create_session_id(
|
||||
$params["sessionId"] ?? "",
|
||||
$post_id,
|
||||
);
|
||||
$stream = $params["stream"] ?? false;
|
||||
$recommended_title = "";
|
||||
$chat_history = $params["chatHistory"] ?? [];
|
||||
$post_config = $this->sidebar->resolve_post_config_from_request(
|
||||
$params,
|
||||
$post_id,
|
||||
);
|
||||
$post_config_context = $this->sidebar->build_post_config_context($post_config);
|
||||
$stored_language = get_post_meta(
|
||||
$post_id,
|
||||
"_wpaw_detected_language",
|
||||
true,
|
||||
);
|
||||
$detected_language = $params["detectedLanguage"] ?? $stored_language;
|
||||
$effective_language = $this->sidebar->resolve_language_preference(
|
||||
$post_config,
|
||||
$detected_language,
|
||||
);
|
||||
|
||||
// Auto-save post and link conversation if needed (only for post_id = 0)
|
||||
if (empty($post_id) && !empty($session_id)) {
|
||||
$post_id = $this->sidebar->ensure_conversation_linked_to_post(
|
||||
$session_id,
|
||||
$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 edit this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
// Get plan from post meta.
|
||||
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
||||
|
||||
if (empty($plan)) {
|
||||
return new WP_Error(
|
||||
"no_plan",
|
||||
__(
|
||||
"No plan found. Please generate a plan first.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
if ($stream) {
|
||||
// For streaming, link conversation to post BEFORE getting plan from meta
|
||||
if (empty($post_id) && !empty($session_id)) {
|
||||
$post_id = $this->sidebar->ensure_conversation_linked_to_post(
|
||||
$session_id,
|
||||
$post_id,
|
||||
);
|
||||
}
|
||||
|
||||
// Now get plan after potentially having a valid post_id
|
||||
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
||||
if (empty($plan)) {
|
||||
echo "data: " .
|
||||
wp_json_encode([
|
||||
"type" => "error",
|
||||
"message" =>
|
||||
"No plan found. Please generate a plan first.",
|
||||
]) .
|
||||
"\n\n";
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sidebar->stream_execute_article(
|
||||
$plan,
|
||||
$post_id,
|
||||
$post_config,
|
||||
$effective_language,
|
||||
$session_id,
|
||||
);
|
||||
exit();
|
||||
}
|
||||
|
||||
$plan = $this->sidebar->ensure_plan_sections_with_tasks($plan);
|
||||
|
||||
// MEMANTO: Plan execution implies approval.
|
||||
do_action("wpaw_memanto_plan_approved", $post_id, $plan);
|
||||
|
||||
// Update post title from the plan title when available.
|
||||
if (!empty($plan["title"])) {
|
||||
$recommended_title = sanitize_text_field($plan["title"]);
|
||||
if ($post_id > 0) {
|
||||
$post = get_post($post_id);
|
||||
if ($post && current_user_can("edit_post", $post_id)) {
|
||||
if (empty($post->post_title)) {
|
||||
wp_update_post([
|
||||
"ID" => $post_id,
|
||||
"post_title" => $recommended_title,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider for writing task.
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
||||
"writing",
|
||||
);
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
$image_instruction = "IMAGE SUGGESTIONS:
|
||||
- Suggest where images would enhance understanding
|
||||
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
|
||||
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
|
||||
- Good places for images: after introductions, before complex explanations, to show examples
|
||||
- Maximum 1-2 image suggestions per section
|
||||
- Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]";
|
||||
if (empty($post_config["include_images"])) {
|
||||
$image_instruction = "IMAGE SUGGESTIONS:
|
||||
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
|
||||
}
|
||||
|
||||
$language_instruction = $this->sidebar->build_language_instruction(
|
||||
$effective_language,
|
||||
"article content",
|
||||
);
|
||||
|
||||
// Build chat history context for continuity
|
||||
$chat_history_context = "";
|
||||
if (!empty($chat_history) && is_array($chat_history)) {
|
||||
$chat_history_context = "\n\n--- CONVERSATION CONTEXT ---\n";
|
||||
foreach ($chat_history as $msg) {
|
||||
$role = isset($msg["role"]) ? ucfirst($msg["role"]) : "Unknown";
|
||||
$content = isset($msg["content"]) ? $msg["content"] : "";
|
||||
if (
|
||||
!empty($content) &&
|
||||
"system" !== strtolower($msg["role"] ?? "")
|
||||
) {
|
||||
$chat_history_context .= "{$role}: {$content}\n\n";
|
||||
}
|
||||
}
|
||||
$chat_history_context .= "--- END CONVERSATION CONTEXT ---\n";
|
||||
$chat_history_context .=
|
||||
"Use the above conversation to understand the user's intent and preferences for this article.";
|
||||
}
|
||||
|
||||
// Build SEO instructions if SEO is enabled
|
||||
$seo_instruction = "";
|
||||
$internal_links_instruction = "";
|
||||
if (
|
||||
!empty($post_config["seo_enabled"]) &&
|
||||
!empty($post_config["seo_focus_keyword"])
|
||||
) {
|
||||
$focus_keyword = $post_config["seo_focus_keyword"];
|
||||
$seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW):
|
||||
- Focus Keyword: \"{$focus_keyword}\"
|
||||
- MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning)
|
||||
- MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words)
|
||||
- Use the focus keyword 5-8 times naturally throughout the article
|
||||
- Include the focus keyword in:
|
||||
* At least 2 H2 or H3 subheadings
|
||||
* The conclusion paragraph
|
||||
- Include 2-3 authoritative outbound links to reputable sources (Wikipedia, official documentation, industry leaders)
|
||||
- When suggesting images, include the focus keyword or related terms in the alt text
|
||||
- Keep the article title under 60 characters";
|
||||
|
||||
// Get internal link suggestions
|
||||
$internal_links = $this->sidebar->suggest_internal_links(
|
||||
$post_id,
|
||||
$focus_keyword,
|
||||
3,
|
||||
);
|
||||
if (!empty($internal_links)) {
|
||||
$internal_links_instruction =
|
||||
"\n\nINTERNAL LINKS (optional - use where contextually relevant):\n";
|
||||
foreach ($internal_links as $link) {
|
||||
$internal_links_instruction .= "- [{$link["title"]}]({$link["url"]})\n";
|
||||
}
|
||||
$internal_links_instruction .=
|
||||
"Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'.";
|
||||
}
|
||||
}
|
||||
|
||||
// Build system prompt for article generation.
|
||||
$system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
|
||||
|
||||
ANTI-ROBOT RULES:
|
||||
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
|
||||
- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
|
||||
- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
|
||||
- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
|
||||
|
||||
GEO/SEO STRATEGY:
|
||||
- Answer the implicit user intent directly and immediately in the first paragraph.
|
||||
- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
|
||||
- Use bullet points or numbered lists where they make data easier to scan.
|
||||
|
||||
CRITICAL LANGUAGE REQUIREMENT:
|
||||
{$language_instruction}
|
||||
{$post_config_context}
|
||||
{$chat_history_context}
|
||||
|
||||
Follow these guidelines:
|
||||
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
|
||||
- Embed secondary keywords naturally as concepts, without forcing exact matches
|
||||
- For code blocks, use proper syntax highlighting (e.g., ```php)
|
||||
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
|
||||
- Write for the specified difficulty level
|
||||
{$seo_instruction}
|
||||
{$internal_links_instruction}
|
||||
|
||||
IMAGE SUGGESTIONS:
|
||||
- Suggest where images would enhance understanding (diagrams, screenshots)
|
||||
- Place image suggestions on their own line: [IMAGE: descriptive alt text]
|
||||
- Maximum 1 image per section
|
||||
{$image_instruction}";
|
||||
|
||||
// Generate content for each section.
|
||||
$blocks = [];
|
||||
$total_cost = 0;
|
||||
|
||||
$sections_to_write = [];
|
||||
foreach ($plan["sections"] as $index => $section) {
|
||||
$status = $section["status"] ?? "pending";
|
||||
if ("done" === $status) {
|
||||
continue;
|
||||
}
|
||||
$sections_to_write[$index] = $section;
|
||||
}
|
||||
|
||||
foreach ($sections_to_write as $section) {
|
||||
$heading = $section["heading"] ?? ($section["title"] ?? "");
|
||||
$section_prompt = $heading
|
||||
? "Write the \"{$heading}\" section.\n\n"
|
||||
: "Write the next section.\n\n";
|
||||
$section_prompt .= "Content requirements:\n";
|
||||
|
||||
if (!empty($section["content"]) && is_array($section["content"])) {
|
||||
foreach ($section["content"] as $item) {
|
||||
if (!empty($item["content"])) {
|
||||
$section_prompt .= "- {$item["content"]}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$messages = [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => $system_prompt,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => $section_prompt,
|
||||
],
|
||||
];
|
||||
|
||||
$response = $provider->chat(
|
||||
$messages,
|
||||
["temperature" => 0.8],
|
||||
"execution",
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return new WP_Error(
|
||||
"execution_error",
|
||||
$response->get_error_message(),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
// Add section blocks.
|
||||
if ($heading) {
|
||||
$blocks[] = [
|
||||
"type" => "heading",
|
||||
"content" => $heading,
|
||||
"level" => 2,
|
||||
];
|
||||
}
|
||||
|
||||
$section_blocks = WP_Agentic_Writer_Markdown_Parser::parse(
|
||||
$response["content"],
|
||||
);
|
||||
if (!empty($section_blocks)) {
|
||||
$first_block = $section_blocks[0];
|
||||
if (
|
||||
isset($first_block["blockName"]) &&
|
||||
"core/heading" === $first_block["blockName"]
|
||||
) {
|
||||
$first_heading = $first_block["attrs"]["content"] ?? "";
|
||||
if (
|
||||
$heading &&
|
||||
$first_heading &&
|
||||
0 === strcasecmp(trim($first_heading), trim($heading))
|
||||
) {
|
||||
array_shift($section_blocks);
|
||||
}
|
||||
}
|
||||
foreach ($section_blocks as $block) {
|
||||
$blocks[] = $block;
|
||||
}
|
||||
} else {
|
||||
$blocks[] = [
|
||||
"type" => "paragraph",
|
||||
"content" => $response["content"],
|
||||
];
|
||||
}
|
||||
|
||||
$total_cost += $response["cost"];
|
||||
|
||||
// MEMANTO: Remember section written.
|
||||
$section_id = $section["id"] ?? sanitize_title($heading);
|
||||
do_action(
|
||||
"wpaw_memanto_section_written",
|
||||
$post_id,
|
||||
$section_id,
|
||||
$heading,
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($sections_to_write)) {
|
||||
foreach (array_keys($sections_to_write) as $section_index) {
|
||||
$plan["sections"][$section_index]["status"] = "done";
|
||||
}
|
||||
if ($post_id > 0) {
|
||||
update_post_meta($post_id, "_wpaw_plan", $plan);
|
||||
}
|
||||
}
|
||||
|
||||
// Track total cost.
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$this->sidebar->get_provider_execution_model($provider, "execution"),
|
||||
"execution",
|
||||
0,
|
||||
0,
|
||||
$total_cost,
|
||||
$provider_result,
|
||||
"",
|
||||
"success",
|
||||
);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"blocks" => $blocks,
|
||||
"cost" => $total_cost,
|
||||
"recommended_title" => $recommended_title,
|
||||
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
||||
$provider_result,
|
||||
$this->sidebar->get_provider_execution_model($provider, "execution"),
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reformat blocks request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_reformat_blocks($request)
|
||||
{
|
||||
$params = $request->get_json_params();
|
||||
$blocks = $params["blocks"] ?? [];
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$recommended_title = "";
|
||||
$title_updated = false;
|
||||
|
||||
if (empty($blocks) || !is_array($blocks)) {
|
||||
return new WP_Error(
|
||||
"no_blocks",
|
||||
__("Blocks are required to reformat.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
// 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 edit this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
if ($post_id > 0) {
|
||||
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
||||
if (is_array($plan) && !empty($plan["title"])) {
|
||||
$recommended_title = sanitize_text_field($plan["title"]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
$client_id =
|
||||
$block["clientId"] ?? ($block["attrs"]["clientId"] ?? "");
|
||||
$block_type =
|
||||
$block["name"] ?? ($block["blockName"] ?? "core/paragraph");
|
||||
$block_attrs = $block["attributes"] ?? ($block["attrs"] ?? []);
|
||||
|
||||
if (empty($client_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("core/paragraph" !== $block_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $this->sidebar->extract_block_content_from_attrs(
|
||||
$block_type,
|
||||
$block_attrs,
|
||||
);
|
||||
if ("" === trim((string) $content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed_blocks = WP_Agentic_Writer_Markdown_Parser::parse($content);
|
||||
if (empty($parsed_blocks)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
"clientId" => $client_id,
|
||||
"blocks" => $parsed_blocks,
|
||||
];
|
||||
}
|
||||
|
||||
if ($post_id > 0 && "" !== $recommended_title) {
|
||||
$post = get_post($post_id);
|
||||
if ($post && current_user_can("edit_post", $post_id)) {
|
||||
if (empty($post->post_title)) {
|
||||
wp_update_post([
|
||||
"ID" => $post_id,
|
||||
"post_title" => $recommended_title,
|
||||
]);
|
||||
$title_updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"results" => $results,
|
||||
"recommended_title" => $recommended_title,
|
||||
"title_updated" => $title_updated,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle save section blocks request.
|
||||
*
|
||||
* @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->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],
|
||||
);
|
||||
}
|
||||
|
||||
$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,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get section blocks request.
|
||||
*
|
||||
* @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->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],
|
||||
);
|
||||
}
|
||||
|
||||
$mapping = get_post_meta($post_id, "_wpaw_section_blocks", true);
|
||||
if (!is_array($mapping)) {
|
||||
$mapping = [];
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"sectionBlocks" => $mapping,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,8 @@
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
if (!defined("ABSPATH")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -14,13 +14,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_create_conversations_table() {
|
||||
global $wpdb;
|
||||
function wpaw_create_conversations_table()
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_conversations';
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
$table_name = $wpdb->prefix . "wpaw_conversations";
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(32) NOT NULL UNIQUE,
|
||||
user_id BIGINT NOT NULL,
|
||||
@@ -37,14 +38,19 @@ function wpaw_create_conversations_table() {
|
||||
INDEX idx_session_id (session_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
require_once ABSPATH . "wp-admin/includes/upgrade.php";
|
||||
dbDelta($sql);
|
||||
|
||||
// Store conversation table migration version (don't override main db version)
|
||||
$existing_version = get_option( 'wpaw_conversations_db_version', '0' );
|
||||
if ( version_compare( $existing_version, '0.1.4', '<' ) ) {
|
||||
update_option( 'wpaw_conversations_db_version', '0.1.4' );
|
||||
}
|
||||
// Migrate session_id column width for UUID support (UUID v4 is 36 chars vs old MD5 32)
|
||||
$wpdb->query(
|
||||
"ALTER TABLE {$table_name} MODIFY session_id VARCHAR(36) NOT NULL",
|
||||
);
|
||||
|
||||
// Store conversation table migration version (don't override main db version)
|
||||
$existing_version = get_option("wpaw_conversations_db_version", "0");
|
||||
if (version_compare($existing_version, "0.1.4", "<")) {
|
||||
update_option("wpaw_conversations_db_version", "0.1.4");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,11 +58,12 @@ function wpaw_create_conversations_table() {
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_drop_conversations_table() {
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'wpaw_conversations';
|
||||
$wpdb->query( "DROP TABLE IF EXISTS {$table_name}" );
|
||||
delete_option( 'wpaw_conversations_db_version' );
|
||||
function wpaw_drop_conversations_table()
|
||||
{
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . "wpaw_conversations";
|
||||
$wpdb->query("DROP TABLE IF EXISTS {$table_name}");
|
||||
delete_option("wpaw_conversations_db_version");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +71,17 @@ function wpaw_drop_conversations_table() {
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_run_migrations() {
|
||||
$current_version = get_option( 'wpaw_conversations_db_version', '0' );
|
||||
function wpaw_run_migrations()
|
||||
{
|
||||
$current_version = get_option("wpaw_conversations_db_version", "0");
|
||||
|
||||
if ( version_compare( $current_version, '0.1.4', '<' ) ) {
|
||||
wpaw_create_conversations_table();
|
||||
}
|
||||
if (version_compare($current_version, "0.1.4", "<")) {
|
||||
wpaw_create_conversations_table();
|
||||
}
|
||||
|
||||
if (version_compare($current_version, "0.2.0", "<")) {
|
||||
wpaw_add_edit_lock_column();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,22 +90,48 @@ function wpaw_run_migrations() {
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_cleanup_old_sessions() {
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'wpaw_conversations';
|
||||
function wpaw_cleanup_old_sessions()
|
||||
{
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . "wpaw_conversations";
|
||||
|
||||
// Archive sessions with no post_id and inactive for 30 days
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table_name} SET status = 'archived' WHERE status = 'active' AND post_id = 0 AND updated_at < %s",
|
||||
date( 'Y-m-d H:i:s', strtotime( '-30 days' ) )
|
||||
)
|
||||
);
|
||||
// Archive sessions with no post_id and inactive for 30 days
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table_name} SET status = 'archived' WHERE status = 'active' AND post_id = 0 AND updated_at < %s",
|
||||
date("Y-m-d H:i:s", strtotime("-30 days")),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add edit_lock column to conversations table.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*/
|
||||
function wpaw_add_edit_lock_column()
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . "wpaw_conversations";
|
||||
|
||||
// Check if column already exists (idempotent).
|
||||
$column_exists = $wpdb->get_results(
|
||||
$wpdb->prepare("SHOW COLUMNS FROM {$table_name} LIKE %s", "edit_lock"),
|
||||
);
|
||||
|
||||
if (empty($column_exists)) {
|
||||
$wpdb->query(
|
||||
"ALTER TABLE {$table_name} ADD COLUMN edit_lock VARCHAR(64) DEFAULT NULL",
|
||||
);
|
||||
}
|
||||
|
||||
update_option("wpaw_conversations_db_version", "0.2.0");
|
||||
}
|
||||
|
||||
// Register cron job for cleanup
|
||||
add_action( 'wpaw_cleanup_old_sessions', 'wpaw_cleanup_old_sessions' );
|
||||
add_action("wpaw_cleanup_old_sessions", "wpaw_cleanup_old_sessions");
|
||||
|
||||
if ( ! wp_next_scheduled( 'wpaw_cleanup_old_sessions' ) ) {
|
||||
wp_schedule_event( time(), 'daily', 'wpaw_cleanup_old_sessions' );
|
||||
}
|
||||
if (!wp_next_scheduled("wpaw_cleanup_old_sessions")) {
|
||||
wp_schedule_event(time(), "daily", "wpaw_cleanup_old_sessions");
|
||||
}
|
||||
|
||||
377
includes/class-custom-search-api.php
Normal file
377
includes/class-custom-search-api.php
Normal file
@@ -0,0 +1,377 @@
|
||||
<?php
|
||||
/**
|
||||
* Custom Search API Integration
|
||||
*
|
||||
* Handles fetching web search results using generic adapters (9Router, Brave, Tavily, Serper)
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if (!defined("ABSPATH")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Custom_Search_API
|
||||
{
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_Agentic_Writer_Custom_Search_API
|
||||
*/
|
||||
public static function get_instance()
|
||||
{
|
||||
static $instance = null;
|
||||
|
||||
if (null === $instance) {
|
||||
$instance = new self();
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a web search based on configured search engine.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $query Required. The user's search query.
|
||||
* @param int $count Optional. Number of results to return. Default 3.
|
||||
* @return array|WP_Error Array of formatted search results, or WP_Error on failure.
|
||||
*/
|
||||
public function search($query, $count = 3)
|
||||
{
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$api_key = $settings["brave_search_api_key"] ?? "";
|
||||
$engine = $settings["search_engine"] ?? "9router";
|
||||
$base_url = $settings["custom_search_url"] ?? "";
|
||||
|
||||
// Auto fallback to 9Router if not openrouter/auto
|
||||
if ($engine === "auto" || $engine === "openrouter") {
|
||||
$engine = "9router";
|
||||
}
|
||||
|
||||
if (empty($api_key)) {
|
||||
return new WP_Error(
|
||||
"search_api_key_missing",
|
||||
__(
|
||||
"Search API Key is missing. Please configure it in WP Agentic Writer settings under Tools.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Check cache first to prevent burning API limits
|
||||
$cache_key =
|
||||
"wpaw_custom_search_" . md5($engine . "_" . $query . "_" . $count);
|
||||
$cached_results = get_transient($cache_key);
|
||||
if (false !== $cached_results) {
|
||||
return $cached_results;
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// ADAPTER ROUTING
|
||||
// -------------------------------------------------------------
|
||||
switch ($engine) {
|
||||
case "brave":
|
||||
$results = $this->search_brave(
|
||||
$query,
|
||||
$count,
|
||||
$api_key,
|
||||
$base_url,
|
||||
);
|
||||
break;
|
||||
case "tavily":
|
||||
$results = $this->search_tavily(
|
||||
$query,
|
||||
$count,
|
||||
$api_key,
|
||||
$base_url,
|
||||
);
|
||||
break;
|
||||
case "serper":
|
||||
$results = $this->search_serper(
|
||||
$query,
|
||||
$count,
|
||||
$api_key,
|
||||
$base_url,
|
||||
);
|
||||
break;
|
||||
case "9router":
|
||||
default:
|
||||
$results = $this->search_9router(
|
||||
$query,
|
||||
$count,
|
||||
$api_key,
|
||||
$base_url,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
if (is_wp_error($results)) {
|
||||
return $results;
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
return []; // No results found
|
||||
}
|
||||
|
||||
// Cache results for 1 hour to prevent redundant API calls
|
||||
set_transient($cache_key, $results, HOUR_IN_SECONDS);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver: 9Router Proxy (Generic OpenAI-style search endpoint)
|
||||
*/
|
||||
private function search_9router($query, $count, $api_key, $base_url)
|
||||
{
|
||||
$url = !empty($base_url)
|
||||
? $base_url
|
||||
: "http://localhost:20128/v1/search";
|
||||
|
||||
$body = [
|
||||
"model" => "gemini", // generic
|
||||
"query" => $query,
|
||||
"search_type" => "web",
|
||||
"max_results" => absint($count),
|
||||
];
|
||||
|
||||
$response = wp_remote_post($url, [
|
||||
"headers" => [
|
||||
"Content-Type" => "application/json",
|
||||
"Authorization" => "Bearer " . $api_key,
|
||||
],
|
||||
"body" => wp_json_encode($body),
|
||||
"timeout" => 15,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$http_code = wp_remote_retrieve_response_code($response);
|
||||
$body_response = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (200 !== $http_code) {
|
||||
return new WP_Error(
|
||||
"search_error",
|
||||
"9Router Search Error: " .
|
||||
($body_response["error"]["message"] ?? "Unknown"),
|
||||
);
|
||||
}
|
||||
|
||||
$formatted = [];
|
||||
if (
|
||||
!empty($body_response["data"]) &&
|
||||
is_array($body_response["data"])
|
||||
) {
|
||||
foreach ($body_response["data"] as $result) {
|
||||
$formatted[] = [
|
||||
"title" => $result["title"] ?? "",
|
||||
"url" => $result["url"] ?? "",
|
||||
"description" =>
|
||||
$result["content"] ?? ($result["description"] ?? ""),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver: Tavily API
|
||||
*/
|
||||
private function search_tavily($query, $count, $api_key, $base_url)
|
||||
{
|
||||
$url = !empty($base_url) ? $base_url : "https://api.tavily.com/search";
|
||||
|
||||
$body = [
|
||||
"api_key" => $api_key,
|
||||
"query" => $query,
|
||||
"search_depth" => "basic",
|
||||
"max_results" => absint($count),
|
||||
];
|
||||
|
||||
$response = wp_remote_post($url, [
|
||||
"headers" => ["Content-Type" => "application/json"],
|
||||
"body" => wp_json_encode($body),
|
||||
"timeout" => 15,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$http_code = wp_remote_retrieve_response_code($response);
|
||||
$body_response = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (200 !== $http_code) {
|
||||
return new WP_Error(
|
||||
"search_error",
|
||||
"Tavily Search Error: " .
|
||||
($body_response["detail"] ?? "Unknown"),
|
||||
);
|
||||
}
|
||||
|
||||
$formatted = [];
|
||||
if (
|
||||
!empty($body_response["results"]) &&
|
||||
is_array($body_response["results"])
|
||||
) {
|
||||
foreach ($body_response["results"] as $result) {
|
||||
$formatted[] = [
|
||||
"title" => $result["title"] ?? "",
|
||||
"url" => $result["url"] ?? "",
|
||||
"description" => $result["content"] ?? "",
|
||||
];
|
||||
}
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver: Serper.dev
|
||||
*/
|
||||
private function search_serper($query, $count, $api_key, $base_url)
|
||||
{
|
||||
$url = !empty($base_url)
|
||||
? $base_url
|
||||
: "https://google.serper.dev/search";
|
||||
|
||||
$body = [
|
||||
"q" => $query,
|
||||
"num" => absint($count),
|
||||
];
|
||||
|
||||
$response = wp_remote_post($url, [
|
||||
"headers" => [
|
||||
"Content-Type" => "application/json",
|
||||
"X-API-KEY" => $api_key,
|
||||
],
|
||||
"body" => wp_json_encode($body),
|
||||
"timeout" => 15,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$http_code = wp_remote_retrieve_response_code($response);
|
||||
$body_response = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (200 !== $http_code) {
|
||||
return new WP_Error(
|
||||
"search_error",
|
||||
"Serper Search Error: " .
|
||||
($body_response["message"] ?? "Unknown"),
|
||||
);
|
||||
}
|
||||
|
||||
$formatted = [];
|
||||
if (
|
||||
!empty($body_response["organic"]) &&
|
||||
is_array($body_response["organic"])
|
||||
) {
|
||||
foreach ($body_response["organic"] as $result) {
|
||||
$formatted[] = [
|
||||
"title" => $result["title"] ?? "",
|
||||
"url" => $result["link"] ?? "",
|
||||
"description" => $result["snippet"] ?? "",
|
||||
];
|
||||
}
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Driver: Brave Search API
|
||||
*/
|
||||
private function search_brave($query, $count, $api_key, $base_url)
|
||||
{
|
||||
$base = !empty($base_url)
|
||||
? $base_url
|
||||
: "https://api.search.brave.com/res/v1/web/search";
|
||||
$url = add_query_arg(
|
||||
[
|
||||
"q" => urlencode($query),
|
||||
"count" => absint($count),
|
||||
"text_decorations" => 0,
|
||||
"spellcheck" => 1,
|
||||
],
|
||||
$base,
|
||||
);
|
||||
|
||||
$response = wp_remote_get($url, [
|
||||
"headers" => [
|
||||
"Accept" => "application/json",
|
||||
"Accept-Encoding" => "gzip",
|
||||
"X-Subscription-Token" => $api_key,
|
||||
],
|
||||
"timeout" => 15,
|
||||
]);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$http_code = wp_remote_retrieve_response_code($response);
|
||||
$body_response = json_decode(wp_remote_retrieve_body($response), true);
|
||||
|
||||
if (200 !== $http_code) {
|
||||
return new WP_Error(
|
||||
"search_error",
|
||||
"Brave Search Error: " .
|
||||
($body_response["message"] ?? "Unknown"),
|
||||
);
|
||||
}
|
||||
|
||||
$formatted = [];
|
||||
if (
|
||||
!empty($body_response["web"]["results"]) &&
|
||||
is_array($body_response["web"]["results"])
|
||||
) {
|
||||
foreach ($body_response["web"]["results"] as $result) {
|
||||
$formatted[] = [
|
||||
"title" => $result["title"] ?? "",
|
||||
"url" => $result["url"] ?? "",
|
||||
"description" => $result["description"] ?? "",
|
||||
];
|
||||
}
|
||||
}
|
||||
return $formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats search results into a markdown context block for LLM System Prompt injection.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $results Search results array.
|
||||
* @param string $query Original query.
|
||||
* @return string Formatted markdown context string.
|
||||
*/
|
||||
public function format_results_for_llm($results, $query)
|
||||
{
|
||||
if (empty($results) || is_wp_error($results)) {
|
||||
return "No reliable web search results found for: {$query}";
|
||||
}
|
||||
|
||||
$markdown = "## LIVE WEB SEARCH CONTEXT\n";
|
||||
$markdown .= "> You successfully searched the internet for: \"{$query}\"\n";
|
||||
$markdown .=
|
||||
"> Please incorporate the following real-time data into your answer:\n\n";
|
||||
|
||||
$counter = 1;
|
||||
foreach ($results as $item) {
|
||||
$markdown .= "{$counter}. **{$item["title"]}**\n";
|
||||
$markdown .= " URL: {$item["url"]}\n";
|
||||
$markdown .= " Summary: {$item["description"]}\n\n";
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$markdown .= "---------------------------\n";
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -42,6 +42,34 @@ class WP_Agentic_Writer_Memanto_Client
|
||||
*/
|
||||
private $health_cache = null;
|
||||
|
||||
/**
|
||||
* Circuit breaker transient key.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const CIRCUIT_TRANSIENT = "wpaw_memanto_circuit";
|
||||
|
||||
/**
|
||||
* Circuit breaker timestamp transient key.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const CIRCUIT_SINCE_TRANSIENT = "wpaw_memanto_circuit_since";
|
||||
|
||||
/**
|
||||
* Circuit breaker timeout in seconds (try again after this).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const CIRCUIT_TIMEOUT = 60;
|
||||
|
||||
/**
|
||||
* Circuit breaker max duration in seconds (stay open max this long).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
private const CIRCUIT_MAX_DURATION = 300;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
@@ -56,14 +84,85 @@ class WP_Agentic_Writer_Memanto_Client
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
* Create a one-off client for a specific base URL.
|
||||
*
|
||||
* @param string $base_url MEMANTO base URL.
|
||||
* @return WP_Agentic_Writer_Memanto_Client
|
||||
*/
|
||||
private function __construct()
|
||||
public static function for_base_url($base_url)
|
||||
{
|
||||
return new self($base_url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string|null $base_url Optional MEMANTO base URL override.
|
||||
*/
|
||||
private function __construct($base_url = null)
|
||||
{
|
||||
if (null !== $base_url) {
|
||||
$this->base_url = untrailingslashit($base_url);
|
||||
return;
|
||||
}
|
||||
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$this->base_url = untrailingslashit($settings["memanto_url"] ?? "");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Circuit Breaker
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* Check if circuit breaker allows requests.
|
||||
*
|
||||
* @return bool True if circuit is closed (requests allowed), false if open (skip).
|
||||
*/
|
||||
private function check_circuit_breaker()
|
||||
{
|
||||
$state = get_transient(self::CIRCUIT_TRANSIENT);
|
||||
|
||||
if ($state !== "open") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Circuit is open — check if timeout has passed.
|
||||
$since = get_transient(self::CIRCUIT_SINCE_TRANSIENT);
|
||||
|
||||
if ($since && time() - $since < self::CIRCUIT_TIMEOUT) {
|
||||
wpaw_debug_log("MEMANTO circuit breaker is open, skipping request");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Timeout passed — try again (half-open state).
|
||||
delete_transient(self::CIRCUIT_TRANSIENT);
|
||||
delete_transient(self::CIRCUIT_SINCE_TRANSIENT);
|
||||
wpaw_debug_log("MEMANTO circuit breaker resetting, attempting request");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trip the circuit breaker (open state).
|
||||
*
|
||||
* Once open, requests are skipped for CIRCUIT_TIMEOUT seconds,
|
||||
* then it retries. Max duration is CIRCUIT_MAX_DURATION.
|
||||
*/
|
||||
private function trip_circuit_breaker()
|
||||
{
|
||||
set_transient(
|
||||
self::CIRCUIT_TRANSIENT,
|
||||
"open",
|
||||
self::CIRCUIT_MAX_DURATION,
|
||||
);
|
||||
set_transient(
|
||||
self::CIRCUIT_SINCE_TRANSIENT,
|
||||
time(),
|
||||
self::CIRCUIT_MAX_DURATION,
|
||||
);
|
||||
wpaw_debug_log("MEMANTO circuit breaker tripped");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Configuration & Health
|
||||
// =========================================================================
|
||||
@@ -98,6 +197,7 @@ class WP_Agentic_Writer_Memanto_Client
|
||||
{
|
||||
return $this->is_enabled() &&
|
||||
$this->is_configured() &&
|
||||
$this->check_circuit_breaker() &&
|
||||
$this->is_healthy();
|
||||
}
|
||||
|
||||
@@ -527,7 +627,11 @@ class WP_Agentic_Writer_Memanto_Client
|
||||
"timeout" => 10,
|
||||
]);
|
||||
|
||||
return $this->parse_response($response);
|
||||
$result = $this->parse_response($response);
|
||||
if (is_wp_error($result)) {
|
||||
$this->trip_circuit_breaker();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -575,7 +679,11 @@ class WP_Agentic_Writer_Memanto_Client
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parse_response($response);
|
||||
$result = $this->parse_response($response);
|
||||
if (is_wp_error($result)) {
|
||||
$this->trip_circuit_breaker();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
if (!defined("ABSPATH")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -21,241 +21,258 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
* $defaults = WPAW_Model_Registry::get_task_defaults();
|
||||
* $registry = WPAW_Model_Registry::get_registry();
|
||||
*/
|
||||
class WPAW_Model_Registry {
|
||||
class WPAW_Model_Registry
|
||||
{
|
||||
/**
|
||||
* Task type constants.
|
||||
*/
|
||||
const TASK_CHAT = "chat";
|
||||
const TASK_CLARITY = "clarity";
|
||||
const TASK_PLANNING = "planning";
|
||||
const TASK_WRITING = "writing";
|
||||
const TASK_EXECUTION = "execution";
|
||||
const TASK_REFINEMENT = "refinement";
|
||||
const TASK_ANALYSIS = "analysis";
|
||||
const TASK_SUMMARIZE = "summarize";
|
||||
const TASK_IMAGE = "image";
|
||||
|
||||
/**
|
||||
* Task type constants.
|
||||
*/
|
||||
const TASK_CHAT = 'chat';
|
||||
const TASK_CLARITY = 'clarity';
|
||||
const TASK_PLANNING = 'planning';
|
||||
const TASK_WRITING = 'writing';
|
||||
const TASK_EXECUTION = 'execution';
|
||||
const TASK_REFINEMENT = 'refinement';
|
||||
const TASK_ANALYSIS = 'analysis';
|
||||
const TASK_SUMMARIZE = 'summarize';
|
||||
const TASK_IMAGE = 'image';
|
||||
/**
|
||||
* Get the complete model registry.
|
||||
*
|
||||
* Structure:
|
||||
* 'task_type' => [
|
||||
* 'default' => 'model_id',
|
||||
* 'fallback' => 'model_id', // optional
|
||||
* 'label' => 'Human-readable name',
|
||||
* 'description' => 'What this model is used for',
|
||||
* 'supported_providers' => ['openrouter', 'local', 'codex'], // optional
|
||||
* 'capabilities' => ['chat', 'streaming', 'vision'], // optional
|
||||
* ]
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Model registry.
|
||||
*/
|
||||
public static function get_registry()
|
||||
{
|
||||
return [
|
||||
"chat" => [
|
||||
"default" => "google/gemini-2.5-flash",
|
||||
"fallback" => "google/gemini-2.0-flash-exp",
|
||||
"label" => "Chat Model",
|
||||
"description" => "Discussion, research, and recommendations",
|
||||
"capabilities" => ["chat", "streaming", "reasoning"],
|
||||
],
|
||||
"clarity" => [
|
||||
"default" => "google/gemini-2.5-flash",
|
||||
"fallback" => "google/gemini-2.0-flash-exp",
|
||||
"label" => "Clarity Model",
|
||||
"description" => "Prompt analysis and quiz generation",
|
||||
"capabilities" => ["chat", "streaming"],
|
||||
],
|
||||
"planning" => [
|
||||
"default" => "google/gemini-2.5-flash",
|
||||
"fallback" => "google/gemini-2.0-flash-exp",
|
||||
"label" => "Planning Model",
|
||||
"description" => "Article outline and structure generation",
|
||||
"capabilities" => ["chat", "streaming"],
|
||||
],
|
||||
"writing" => [
|
||||
"default" => "anthropic/claude-3.5-haiku",
|
||||
"fallback" => "google/gemini-2.5-flash",
|
||||
"label" => "Writing Model",
|
||||
"description" => "Article content generation",
|
||||
"capabilities" => ["chat", "streaming", "long_context"],
|
||||
],
|
||||
"execution" => [
|
||||
"default" => "anthropic/claude-3.5-haiku",
|
||||
"fallback" => "google/gemini-2.5-flash",
|
||||
"label" => "Execution Model",
|
||||
"description" => "Article section writing (alias for writing)",
|
||||
"capabilities" => ["chat", "streaming"],
|
||||
],
|
||||
"refinement" => [
|
||||
"default" => "anthropic/claude-sonnet-4",
|
||||
"fallback" => "anthropic/claude-3.5-haiku",
|
||||
"label" => "Refinement Model",
|
||||
"description" => "Paragraph edits, rewrites, and improvements",
|
||||
"capabilities" => ["chat", "streaming"],
|
||||
],
|
||||
"analysis" => [
|
||||
"default" => "google/gemini-2.5-flash",
|
||||
"fallback" => "anthropic/claude-3.5-haiku",
|
||||
"label" => "Analysis Model",
|
||||
"description" => "Content analysis and improvement suggestions",
|
||||
"capabilities" => ["chat", "streaming"],
|
||||
],
|
||||
"summarize" => [
|
||||
"default" => "google/gemini-2.5-flash",
|
||||
"fallback" => "anthropic/claude-3.5-haiku",
|
||||
"label" => "Summarization Model",
|
||||
"description" => "Context summarization and compression",
|
||||
"capabilities" => ["chat"],
|
||||
],
|
||||
"image" => [
|
||||
"default" => "openai/gpt-4o",
|
||||
"fallback" => "openai/dall-e-3",
|
||||
"label" => "Image Generation Model",
|
||||
"description" => "Image generation for articles",
|
||||
"capabilities" => ["image_generation"],
|
||||
"supported_providers" => ["openrouter"],
|
||||
],
|
||||
"image_models" => [
|
||||
"black-forest-labs/flux-1.1-pro" => "FLUX 1.1 Pro",
|
||||
"black-forest-labs/flux-pro" => "FLUX Pro",
|
||||
"recraft-ai/recraft-v3" => "Recraft V3",
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the complete model registry.
|
||||
*
|
||||
* Structure:
|
||||
* 'task_type' => [
|
||||
* 'default' => 'model_id',
|
||||
* 'fallback' => 'model_id', // optional
|
||||
* 'label' => 'Human-readable name',
|
||||
* 'description' => 'What this model is used for',
|
||||
* 'supported_providers' => ['openrouter', 'local', 'codex'], // optional
|
||||
* 'capabilities' => ['chat', 'streaming', 'vision'], // optional
|
||||
* ]
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Model registry.
|
||||
*/
|
||||
public static function get_registry() {
|
||||
return array(
|
||||
'chat' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'google/gemini-2.0-flash-exp',
|
||||
'label' => 'Chat Model',
|
||||
'description' => 'Discussion, research, and recommendations',
|
||||
'capabilities' => array( 'chat', 'streaming', 'reasoning' ),
|
||||
),
|
||||
'clarity' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'google/gemini-2.0-flash-exp',
|
||||
'label' => 'Clarity Model',
|
||||
'description' => 'Prompt analysis and quiz generation',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'planning' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'google/gemini-2.0-flash-exp',
|
||||
'label' => 'Planning Model',
|
||||
'description' => 'Article outline and structure generation',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'writing' => array(
|
||||
'default' => 'anthropic/claude-3.5-haiku',
|
||||
'fallback' => 'google/gemini-2.5-flash',
|
||||
'label' => 'Writing Model',
|
||||
'description' => 'Article content generation',
|
||||
'capabilities' => array( 'chat', 'streaming', 'long_context' ),
|
||||
),
|
||||
'execution' => array(
|
||||
'default' => 'anthropic/claude-3.5-haiku',
|
||||
'fallback' => 'google/gemini-2.5-flash',
|
||||
'label' => 'Execution Model',
|
||||
'description' => 'Article section writing (alias for writing)',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'refinement' => array(
|
||||
'default' => 'anthropic/claude-3.5-sonnet',
|
||||
'fallback' => 'anthropic/claude-3.5-haiku',
|
||||
'label' => 'Refinement Model',
|
||||
'description' => 'Paragraph edits, rewrites, and improvements',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'analysis' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'anthropic/claude-3.5-haiku',
|
||||
'label' => 'Analysis Model',
|
||||
'description' => 'Content analysis and improvement suggestions',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'summarize' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'anthropic/claude-3.5-haiku',
|
||||
'label' => 'Summarization Model',
|
||||
'description' => 'Context summarization and compression',
|
||||
'capabilities' => array( 'chat' ),
|
||||
),
|
||||
'image' => array(
|
||||
'default' => 'openai/gpt-4o',
|
||||
'fallback' => 'openai/dall-e-3',
|
||||
'label' => 'Image Generation Model',
|
||||
'description' => 'Image generation for articles',
|
||||
'capabilities' => array( 'image_generation' ),
|
||||
'supported_providers' => array( 'openrouter' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Get default model for a task type.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $task Task type (chat, planning, execution, etc).
|
||||
* @return string Default model ID.
|
||||
*/
|
||||
public static function get_default_model($task)
|
||||
{
|
||||
$registry = self::get_registry();
|
||||
$task_data = $registry[$task] ?? $registry["chat"];
|
||||
return $task_data["default"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for a task type.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $task Task type (chat, planning, execution, etc).
|
||||
* @return string Default model ID.
|
||||
*/
|
||||
public static function get_default_model( $task ) {
|
||||
$registry = self::get_registry();
|
||||
$task_data = $registry[ $task ] ?? $registry['chat'];
|
||||
return $task_data['default'];
|
||||
}
|
||||
/**
|
||||
* Get fallback model for a task type.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $task Task type.
|
||||
* @return string Fallback model ID.
|
||||
*/
|
||||
public static function get_fallback_model($task)
|
||||
{
|
||||
$registry = self::get_registry();
|
||||
$task_data = $registry[$task] ?? $registry["chat"];
|
||||
return $task_data["fallback"] ?? $task_data["default"];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback model for a task type.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $task Task type.
|
||||
* @return string Fallback model ID.
|
||||
*/
|
||||
public static function get_fallback_model( $task ) {
|
||||
$registry = self::get_registry();
|
||||
$task_data = $registry[ $task ] ?? $registry['chat'];
|
||||
return $task_data['fallback'] ?? $task_data['default'];
|
||||
}
|
||||
/**
|
||||
* Get all task defaults as key-value pairs.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Task => default_model pairs.
|
||||
*/
|
||||
public static function get_task_defaults()
|
||||
{
|
||||
$registry = self::get_registry();
|
||||
$defaults = [];
|
||||
|
||||
/**
|
||||
* Get all task defaults as key-value pairs.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Task => default_model pairs.
|
||||
*/
|
||||
public static function get_task_defaults() {
|
||||
$registry = self::get_registry();
|
||||
$defaults = array();
|
||||
foreach ($registry as $task => $data) {
|
||||
$defaults[$task] = $data["default"];
|
||||
}
|
||||
|
||||
foreach ( $registry as $task => $data ) {
|
||||
$defaults[ $task ] = $data['default'];
|
||||
}
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
return $defaults;
|
||||
}
|
||||
/**
|
||||
* Get activation defaults (for plugin activation).
|
||||
*
|
||||
* This returns the format expected by the settings option.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Settings-compatible defaults.
|
||||
*/
|
||||
public static function get_activation_defaults()
|
||||
{
|
||||
return [
|
||||
"planning_model" => self::get_default_model("planning"),
|
||||
"execution_model" => self::get_default_model("writing"),
|
||||
"image_model" => self::get_default_model("image"),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activation defaults (for plugin activation).
|
||||
*
|
||||
* This returns the format expected by the settings option.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Settings-compatible defaults.
|
||||
*/
|
||||
public static function get_activation_defaults() {
|
||||
return array(
|
||||
'planning_model' => self::get_default_model( 'planning' ),
|
||||
'execution_model' => self::get_default_model( 'writing' ),
|
||||
'image_model' => self::get_default_model( 'image' ),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Validate a model ID is in the registry.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $model Model ID.
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
public static function is_valid_model($model)
|
||||
{
|
||||
foreach (self::get_registry() as $task_data) {
|
||||
if (
|
||||
$model === $task_data["default"] ||
|
||||
$model === $task_data["fallback"]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a model ID is in the registry.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $model Model ID.
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
public static function is_valid_model( $model ) {
|
||||
foreach ( self::get_registry() as $task_data ) {
|
||||
if ( $model === $task_data['default'] || $model === $task_data['fallback'] ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Get display name for a model ID.
|
||||
*
|
||||
* Extracts a human-readable name from model IDs like
|
||||
* "google/gemini-2.5-flash" -> "Google Gemini 2.5 Flash"
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $model_id Model ID.
|
||||
* @return string Human-readable display name.
|
||||
*/
|
||||
public static function get_model_display_name($model_id)
|
||||
{
|
||||
if (empty($model_id)) {
|
||||
return "Unknown Model";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a model ID.
|
||||
*
|
||||
* Extracts a human-readable name from model IDs like
|
||||
* "google/gemini-2.5-flash" -> "Google Gemini 2.5 Flash"
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $model_id Model ID.
|
||||
* @return string Human-readable display name.
|
||||
*/
|
||||
public static function get_model_display_name( $model_id ) {
|
||||
if ( empty( $model_id ) ) {
|
||||
return 'Unknown Model';
|
||||
}
|
||||
// Handle known model ID patterns
|
||||
$display_names = [
|
||||
"google/gemini-2.5-flash" => "Google Gemini 2.5 Flash",
|
||||
"google/gemini-2.0-flash-exp" => "Google Gemini 2.0 Flash",
|
||||
"google/gemini-2.0-flash-exp:free" => "Google Gemini 2.0 Flash",
|
||||
"anthropic/claude-sonnet-4" => "Anthropic Claude Sonnet 4",
|
||||
"anthropic/claude-3.5-sonnet" => "Anthropic Claude 3.5 Sonnet",
|
||||
"anthropic/claude-3.5-haiku" => "Anthropic Claude 3.5 Haiku",
|
||||
"openai/gpt-4o" => "OpenAI GPT-4o",
|
||||
"openai/dall-e-3" => "OpenAI DALL-E 3",
|
||||
"black-forest-labs/flux-schnell" => "Black Forest Flux Schnell",
|
||||
];
|
||||
|
||||
// Handle known model ID patterns
|
||||
$display_names = array(
|
||||
'google/gemini-2.5-flash' => 'Google Gemini 2.5 Flash',
|
||||
'google/gemini-2.0-flash-exp' => 'Google Gemini 2.0 Flash',
|
||||
'google/gemini-2.0-flash-exp:free' => 'Google Gemini 2.0 Flash',
|
||||
'anthropic/claude-3.5-sonnet' => 'Anthropic Claude 3.5 Sonnet',
|
||||
'anthropic/claude-3.5-haiku' => 'Anthropic Claude 3.5 Haiku',
|
||||
'openai/gpt-4o' => 'OpenAI GPT-4o',
|
||||
'openai/dall-e-3' => 'OpenAI DALL-E 3',
|
||||
'black-forest-labs/flux-schnell' => 'Black Forest Flux Schnell',
|
||||
);
|
||||
if (isset($display_names[$model_id])) {
|
||||
return $display_names[$model_id];
|
||||
}
|
||||
|
||||
if ( isset( $display_names[ $model_id ] ) ) {
|
||||
return $display_names[ $model_id ];
|
||||
}
|
||||
// Generate from model ID: "provider/model-name" -> "Provider Model Name"
|
||||
$parts = explode("/", $model_id);
|
||||
if (count($parts) >= 2) {
|
||||
$provider = ucfirst(str_replace("-", " ", $parts[0]));
|
||||
$name = ucwords(str_replace("-", " ", $parts[1]));
|
||||
return trim($provider . " " . $name);
|
||||
}
|
||||
|
||||
// Generate from model ID: "provider/model-name" -> "Provider Model Name"
|
||||
$parts = explode( '/', $model_id );
|
||||
if ( count( $parts ) >= 2 ) {
|
||||
$provider = ucfirst( str_replace( '-', ' ', $parts[0] ) );
|
||||
$name = ucwords( str_replace( '-', ' ', $parts[1] ) );
|
||||
return trim( $provider . ' ' . $name );
|
||||
}
|
||||
return ucwords(str_replace("-", " ", $model_id));
|
||||
}
|
||||
|
||||
return ucwords( str_replace( '-', ' ', $model_id ) );
|
||||
}
|
||||
/**
|
||||
* Get JavaScript-compatible registry for frontend.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array JS-safe registry data.
|
||||
*/
|
||||
public static function get_frontend_data()
|
||||
{
|
||||
$registry = self::get_registry();
|
||||
$result = [];
|
||||
|
||||
/**
|
||||
* Get JavaScript-compatible registry for frontend.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array JS-safe registry data.
|
||||
*/
|
||||
public static function get_frontend_data() {
|
||||
$registry = self::get_registry();
|
||||
$result = array();
|
||||
foreach ($registry as $task => $data) {
|
||||
$result[$task] = [
|
||||
"default" => $data["default"],
|
||||
"fallback" => $data["fallback"] ?? null,
|
||||
"label" => $data["label"],
|
||||
];
|
||||
}
|
||||
|
||||
foreach ( $registry as $task => $data ) {
|
||||
$result[ $task ] = array(
|
||||
'default' => $data['default'],
|
||||
'fallback' => $data['fallback'] ?? null,
|
||||
'label' => $data['label'],
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,8 +7,8 @@
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
if (!defined("ABSPATH")) {
|
||||
exit();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -17,211 +17,346 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
*
|
||||
* @since 0.2.0
|
||||
*/
|
||||
class WPAW_Provider_Selection_Result {
|
||||
public $provider; // Provider instance
|
||||
public $selected_provider; // Original requested provider name
|
||||
public $actual_provider; // Actually used provider name (may differ if fallback)
|
||||
public $fallback_used; // True if fallback occurred
|
||||
public $warnings; // Array of warning messages
|
||||
class WPAW_Provider_Selection_Result
|
||||
{
|
||||
public $provider; // Provider instance
|
||||
public $selected_provider; // Original requested provider name
|
||||
public $actual_provider; // Actually used provider name (may differ if fallback)
|
||||
public $fallback_used; // True if fallback occurred
|
||||
public $warnings; // Array of warning messages
|
||||
|
||||
public function __construct( $provider, $selected, $actual, $fallback, $warnings = array() ) {
|
||||
$this->provider = $provider;
|
||||
$this->selected_provider = $selected;
|
||||
$this->actual_provider = $actual;
|
||||
$this->fallback_used = $fallback;
|
||||
$this->warnings = $warnings;
|
||||
}
|
||||
public function __construct(
|
||||
$provider,
|
||||
$selected,
|
||||
$actual,
|
||||
$fallback,
|
||||
$warnings = [],
|
||||
) {
|
||||
$this->provider = $provider;
|
||||
$this->selected_provider = $selected;
|
||||
$this->actual_provider = $actual;
|
||||
$this->fallback_used = $fallback;
|
||||
$this->warnings = $warnings;
|
||||
}
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Provider_Manager {
|
||||
/**
|
||||
* Get provider instance for specific task type
|
||||
*
|
||||
* @param string $type Task type (chat, clarity, planning, writing, refinement, image).
|
||||
* @return WPAW_Provider_Selection_Result Provider selection result with metadata.
|
||||
*/
|
||||
public static function get_provider_for_task( $type ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$task_providers = $settings['task_providers'] ?? array();
|
||||
$allow_openrouter_fallback = ! empty( $settings['allow_openrouter_fallback'] );
|
||||
class WP_Agentic_Writer_Provider_Manager
|
||||
{
|
||||
/**
|
||||
* Transient cache TTL for connection test results (5 minutes).
|
||||
*
|
||||
* @var int
|
||||
*/
|
||||
const CONNECTION_TEST_CACHE_TTL = 300;
|
||||
|
||||
// Determine which provider to use for this task
|
||||
$requested_provider = $task_providers[ $type ] ?? 'openrouter';
|
||||
/**
|
||||
* Transient cache key prefix for connection tests.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
const CONNECTION_TEST_CACHE_PREFIX = "wpaw_conn_test_";
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( "WPAW Provider Manager: task={$type}, provider_name={$requested_provider}, task_providers=" . json_encode( $task_providers ) );
|
||||
}
|
||||
/**
|
||||
* Get provider instance for specific task type
|
||||
*
|
||||
* @param string $type Task type (chat, clarity, planning, writing, refinement, image).
|
||||
* @return WPAW_Provider_Selection_Result Provider selection result with metadata.
|
||||
*/
|
||||
public static function get_provider_for_task($type)
|
||||
{
|
||||
$settings = get_option("wp_agentic_writer_settings", []);
|
||||
$task_providers = $settings["task_providers"] ?? [];
|
||||
$allow_openrouter_fallback = !empty(
|
||||
$settings["allow_openrouter_fallback"]
|
||||
);
|
||||
|
||||
$warnings = array();
|
||||
$fallback_used = false;
|
||||
$actual_provider = $requested_provider;
|
||||
// Determine which provider to use for this task
|
||||
$requested_provider = $task_providers[$type] ?? "openrouter";
|
||||
|
||||
// Get provider instance with fallback logic
|
||||
$provider = self::get_provider_instance( $requested_provider, $type );
|
||||
if (defined("WP_DEBUG") && WP_DEBUG) {
|
||||
error_log(
|
||||
"WPAW Provider Manager: task={$type}, provider_name={$requested_provider}, task_providers=" .
|
||||
json_encode($task_providers),
|
||||
);
|
||||
}
|
||||
|
||||
$can_fallback_to_openrouter = ( 'openrouter' === $requested_provider ) || $allow_openrouter_fallback;
|
||||
$warnings = [];
|
||||
$fallback_used = false;
|
||||
$actual_provider = $requested_provider;
|
||||
|
||||
// If provider not configured or unavailable.
|
||||
if ( ! $provider || ! $provider->is_configured() ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( "Provider '{$requested_provider}' not available for task '{$type}'" );
|
||||
}
|
||||
// Get provider instance with fallback logic
|
||||
$provider = self::get_provider_instance($requested_provider, $type);
|
||||
|
||||
// Never silently spend OpenRouter credits when user selected another provider.
|
||||
if ( ! $can_fallback_to_openrouter ) {
|
||||
$warnings[] = "Provider '{$requested_provider}' unavailable. No automatic fallback was applied.";
|
||||
return new WPAW_Provider_Selection_Result(
|
||||
$provider,
|
||||
$requested_provider,
|
||||
$requested_provider,
|
||||
false,
|
||||
$warnings
|
||||
);
|
||||
}
|
||||
$can_fallback_to_openrouter =
|
||||
"openrouter" === $requested_provider || $allow_openrouter_fallback;
|
||||
|
||||
$warnings[] = "Provider '{$requested_provider}' unavailable, fell back to OpenRouter";
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$actual_provider = 'openrouter';
|
||||
$fallback_used = true;
|
||||
}
|
||||
// If provider not configured or unavailable.
|
||||
if (!$provider || !$provider->is_configured()) {
|
||||
if (defined("WP_DEBUG") && WP_DEBUG) {
|
||||
error_log(
|
||||
"Provider '{$requested_provider}' not available for task '{$type}'",
|
||||
);
|
||||
}
|
||||
|
||||
// For local backend, verify it's actually reachable before using it
|
||||
if ( 'local_backend' === $requested_provider && ! $fallback_used && method_exists( $provider, 'test_connection' ) ) {
|
||||
$test_result = $provider->test_connection();
|
||||
if ( is_wp_error( $test_result ) ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( "Local Backend not reachable for task '{$type}'. Error: " . $test_result->get_error_message() );
|
||||
}
|
||||
if ( $can_fallback_to_openrouter ) {
|
||||
$warnings[] = "Local Backend not reachable, fell back to OpenRouter.";
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$actual_provider = 'openrouter';
|
||||
$fallback_used = true;
|
||||
} else {
|
||||
$warnings[] = "Local Backend not reachable. No automatic fallback was applied.";
|
||||
}
|
||||
}
|
||||
}
|
||||
// Never silently spend OpenRouter credits when user selected another provider.
|
||||
if (!$can_fallback_to_openrouter) {
|
||||
$warnings[] = "Provider '{$requested_provider}' unavailable. No automatic fallback was applied.";
|
||||
return new WPAW_Provider_Selection_Result(
|
||||
$provider,
|
||||
$requested_provider,
|
||||
$requested_provider,
|
||||
false,
|
||||
$warnings,
|
||||
);
|
||||
}
|
||||
|
||||
return new WPAW_Provider_Selection_Result(
|
||||
$provider,
|
||||
$requested_provider,
|
||||
$actual_provider,
|
||||
$fallback_used,
|
||||
$warnings
|
||||
);
|
||||
}
|
||||
$warnings[] = "Provider '{$requested_provider}' unavailable, fell back to OpenRouter";
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$actual_provider = "openrouter";
|
||||
$fallback_used = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider instance by name
|
||||
*
|
||||
* @param string $provider_name Provider identifier.
|
||||
* @param string $task_type Task type for validation.
|
||||
* @return WP_Agentic_Writer_AI_Provider_Interface|null Provider instance or null.
|
||||
*/
|
||||
private static function get_provider_instance( $provider_name, $task_type ) {
|
||||
switch ( $provider_name ) {
|
||||
case 'local_backend':
|
||||
if ( ! class_exists( 'WP_Agentic_Writer_Local_Backend_Provider' ) ) {
|
||||
require_once plugin_dir_path( __FILE__ ) . 'class-local-backend-provider.php';
|
||||
}
|
||||
$provider = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
break;
|
||||
// For custom endpoint, verify it's actually reachable before using it.
|
||||
// Use transient cache to avoid testing connection on every request (5 min TTL).
|
||||
if (
|
||||
"local_backend" === $requested_provider &&
|
||||
!$fallback_used &&
|
||||
method_exists($provider, "test_connection")
|
||||
) {
|
||||
$cache_key =
|
||||
self::CONNECTION_TEST_CACHE_PREFIX .
|
||||
md5($requested_provider . $type);
|
||||
$cached_result = get_transient($cache_key);
|
||||
|
||||
case 'codex':
|
||||
if ( ! class_exists( 'WP_Agentic_Writer_Codex_Provider' ) ) {
|
||||
require_once plugin_dir_path( __FILE__ ) . 'class-codex-provider.php';
|
||||
}
|
||||
$provider = new WP_Agentic_Writer_Codex_Provider();
|
||||
break;
|
||||
if (false !== $cached_result) {
|
||||
// Use cached result
|
||||
if (is_wp_error($cached_result)) {
|
||||
$test_result = $cached_result;
|
||||
} else {
|
||||
// Cached success - skip test entirely
|
||||
if (defined("WP_DEBUG") && WP_DEBUG) {
|
||||
error_log(
|
||||
"WPAW: Using cached connection test result for '{$requested_provider}'",
|
||||
);
|
||||
}
|
||||
$test_result = null; // null means "test passed"
|
||||
}
|
||||
} else {
|
||||
// No cache - run the actual test
|
||||
$test_result = $provider->test_connection();
|
||||
|
||||
case 'openrouter':
|
||||
default:
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
break;
|
||||
}
|
||||
// Cache the result for CONNECTION_TEST_CACHE_TTL seconds
|
||||
set_transient(
|
||||
$cache_key,
|
||||
$test_result,
|
||||
self::CONNECTION_TEST_CACHE_TTL,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate provider supports this task type
|
||||
if ( $provider && ! $provider->supports_task_type( $task_type ) ) {
|
||||
error_log( "Provider '{$provider_name}' does not support task type '{$task_type}'" );
|
||||
return null;
|
||||
}
|
||||
if (isset($test_result) && is_wp_error($test_result)) {
|
||||
if (defined("WP_DEBUG") && WP_DEBUG) {
|
||||
error_log(
|
||||
"Custom endpoint not reachable for task '{$type}'. Error: " .
|
||||
$test_result->get_error_message(),
|
||||
);
|
||||
}
|
||||
if ($can_fallback_to_openrouter) {
|
||||
$warnings[] =
|
||||
"Custom endpoint not reachable, fell back to OpenRouter.";
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$actual_provider = "openrouter";
|
||||
$fallback_used = true;
|
||||
} else {
|
||||
$warnings[] =
|
||||
"Custom endpoint not reachable. No automatic fallback was applied.";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
return new WPAW_Provider_Selection_Result(
|
||||
$provider,
|
||||
$requested_provider,
|
||||
$actual_provider,
|
||||
$fallback_used,
|
||||
$warnings,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers with their status
|
||||
*
|
||||
* @return array Array of provider info with name, status, supported tasks.
|
||||
*/
|
||||
public static function get_available_providers() {
|
||||
$providers = array();
|
||||
/**
|
||||
* Get provider instance by name
|
||||
*
|
||||
* @param string $provider_name Provider identifier.
|
||||
* @param string $task_type Task type for validation.
|
||||
* @return WP_Agentic_Writer_AI_Provider_Interface|null Provider instance or null.
|
||||
*/
|
||||
private static function get_provider_instance($provider_name, $task_type)
|
||||
{
|
||||
switch ($provider_name) {
|
||||
case "local_backend":
|
||||
if (!class_exists("WP_Agentic_Writer_Local_Backend_Provider")) {
|
||||
require_once plugin_dir_path(__FILE__) .
|
||||
"class-local-backend-provider.php";
|
||||
}
|
||||
$provider = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
break;
|
||||
|
||||
// OpenRouter (always available)
|
||||
$openrouter = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$providers['openrouter'] = array(
|
||||
'name' => 'OpenRouter',
|
||||
'configured' => $openrouter->is_configured(),
|
||||
'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' ),
|
||||
'icon' => '☁️',
|
||||
);
|
||||
case "codex":
|
||||
if (!class_exists("WP_Agentic_Writer_Codex_Provider")) {
|
||||
require_once plugin_dir_path(__FILE__) .
|
||||
"class-codex-provider.php";
|
||||
}
|
||||
$provider = new WP_Agentic_Writer_Codex_Provider();
|
||||
break;
|
||||
|
||||
// Local Backend
|
||||
if ( class_exists( 'WP_Agentic_Writer_Local_Backend_Provider' ) ) {
|
||||
$local = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
$providers['local_backend'] = array(
|
||||
'name' => 'Local Backend',
|
||||
'configured' => $local->is_configured(),
|
||||
'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
|
||||
'icon' => '🏠',
|
||||
);
|
||||
}
|
||||
case "openrouter":
|
||||
default:
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
break;
|
||||
}
|
||||
|
||||
// Codex
|
||||
if ( class_exists( 'WP_Agentic_Writer_Codex_Provider' ) ) {
|
||||
$codex = new WP_Agentic_Writer_Codex_Provider();
|
||||
$providers['codex'] = array(
|
||||
'name' => 'Codex (OpenAI)',
|
||||
'configured' => $codex->is_configured(),
|
||||
'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
|
||||
'icon' => '🔗',
|
||||
);
|
||||
}
|
||||
// Validate provider supports this task type
|
||||
if ($provider && !$provider->supports_task_type($task_type)) {
|
||||
error_log(
|
||||
"Provider '{$provider_name}' does not support task type '{$task_type}'",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all configured providers
|
||||
*
|
||||
* @return array Results of connection tests.
|
||||
*/
|
||||
public static function test_all_providers() {
|
||||
$results = array();
|
||||
$providers = self::get_available_providers();
|
||||
/**
|
||||
* Get all available providers with their status
|
||||
*
|
||||
* @return array Array of provider info with name, status, supported tasks.
|
||||
*/
|
||||
public static function get_available_providers()
|
||||
{
|
||||
$providers = [];
|
||||
|
||||
foreach ( $providers as $key => $info ) {
|
||||
if ( ! $info['configured'] ) {
|
||||
$results[ $key ] = array(
|
||||
'success' => false,
|
||||
'message' => 'Not configured',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
// OpenRouter (always available)
|
||||
$openrouter = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$providers["openrouter"] = [
|
||||
"name" => "OpenRouter",
|
||||
"configured" => $openrouter->is_configured(),
|
||||
"supports" => [
|
||||
"chat",
|
||||
"clarity",
|
||||
"planning",
|
||||
"writing",
|
||||
"refinement",
|
||||
"image",
|
||||
],
|
||||
"icon" => "☁️",
|
||||
];
|
||||
|
||||
$provider = self::get_provider_instance( $key, 'chat' );
|
||||
if ( $provider ) {
|
||||
$test_result = $provider->test_connection();
|
||||
$results[ $key ] = is_wp_error( $test_result )
|
||||
? array(
|
||||
'success' => false,
|
||||
'message' => $test_result->get_error_message(),
|
||||
)
|
||||
: $test_result;
|
||||
}
|
||||
}
|
||||
// Custom Endpoint
|
||||
if (class_exists("WP_Agentic_Writer_Local_Backend_Provider")) {
|
||||
$local = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
$providers["local_backend"] = [
|
||||
"name" => "Custom Endpoint",
|
||||
"configured" => $local->is_configured(),
|
||||
"supports" => [
|
||||
"chat",
|
||||
"clarity",
|
||||
"planning",
|
||||
"writing",
|
||||
"refinement",
|
||||
],
|
||||
"icon" => "🏠",
|
||||
];
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
// Codex
|
||||
if (class_exists("WP_Agentic_Writer_Codex_Provider")) {
|
||||
$codex = new WP_Agentic_Writer_Codex_Provider();
|
||||
$providers["codex"] = [
|
||||
"name" => "Codex (OpenAI)",
|
||||
"configured" => $codex->is_configured(),
|
||||
"supports" => [
|
||||
"chat",
|
||||
"clarity",
|
||||
"planning",
|
||||
"writing",
|
||||
"refinement",
|
||||
],
|
||||
"icon" => "🔗",
|
||||
];
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all configured providers
|
||||
*
|
||||
* @return array Results of connection tests.
|
||||
*/
|
||||
public static function test_all_providers()
|
||||
{
|
||||
$results = [];
|
||||
$providers = self::get_available_providers();
|
||||
|
||||
foreach ($providers as $key => $info) {
|
||||
if (!$info["configured"]) {
|
||||
$results[$key] = [
|
||||
"success" => false,
|
||||
"message" => "Not configured",
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$provider = self::get_provider_instance($key, "chat");
|
||||
if ($provider) {
|
||||
$test_result = $provider->test_connection();
|
||||
$results[$key] = is_wp_error($test_result)
|
||||
? [
|
||||
"success" => false,
|
||||
"message" => $test_result->get_error_message(),
|
||||
]
|
||||
: $test_result;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the connection test transient cache.
|
||||
* Call this when local backend settings are updated.
|
||||
*
|
||||
* @return int Number of cache entries deleted.
|
||||
*/
|
||||
public static function clear_connection_test_cache()
|
||||
{
|
||||
global $wpdb;
|
||||
|
||||
$prefix = self::CONNECTION_TEST_CACHE_PREFIX;
|
||||
$count = 0;
|
||||
|
||||
// Delete all transients with our prefix
|
||||
$transients = $wpdb->get_col(
|
||||
$wpdb->prepare(
|
||||
"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
|
||||
$wpdb->esc_like("_transient_") . $prefix . "%",
|
||||
),
|
||||
);
|
||||
|
||||
foreach ($transients as $transient) {
|
||||
// Strip '_transient_' prefix if present
|
||||
$key = $transient;
|
||||
if (0 === strpos($key, "_transient_")) {
|
||||
$key = substr($key, 11);
|
||||
}
|
||||
if (delete_transient($key)) {
|
||||
$count++;
|
||||
}
|
||||
}
|
||||
|
||||
if (defined("WP_DEBUG") && WP_DEBUG) {
|
||||
error_log("WPAW: Cleared {$count} connection test cache entries");
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
}
|
||||
|
||||
216
includes/class-rate-limiter.php
Normal file
216
includes/class-rate-limiter.php
Normal file
@@ -0,0 +1,216 @@
|
||||
<?php
|
||||
/**
|
||||
* Rate Limiter for WP Agentic Writer REST Endpoints
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WPAW_Rate_Limiter
|
||||
*
|
||||
* Simple WordPress transient-based rate limiter for REST endpoints.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WPAW_Rate_Limiter {
|
||||
|
||||
/**
|
||||
* Default rate limits per endpoint type.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private static $default_limits = [
|
||||
'chat' => [ 'limit' => 60, 'window' => 60 ], // 60/min
|
||||
'chat_stream' => [ 'limit' => 60, 'window' => 60 ], // 60/min
|
||||
'generate_plan' => [ 'limit' => 10, 'window' => 60 ], // 10/min
|
||||
'stream_plan' => [ 'limit' => 10, 'window' => 60 ], // 10/min
|
||||
'execute_article' => [ 'limit' => 10, 'window' => 60 ], // 10/min
|
||||
'stream_article' => [ 'limit' => 10, 'window' => 60 ], // 10/min
|
||||
'generate_image' => [ 'limit' => 10, 'window' => 60 ], // 10/min
|
||||
'seo_audit' => [ 'limit' => 20, 'window' => 60 ], // 20/min
|
||||
'refine' => [ 'limit' => 30, 'window' => 60 ], // 30/min
|
||||
'default' => [ 'limit' => 30, 'window' => 60 ], // 30/min fallback
|
||||
];
|
||||
|
||||
/**
|
||||
* Generate cache key for rate limit tracking.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param int $user_id User ID.
|
||||
* @param string $endpoint Endpoint name.
|
||||
* @return string Cache key.
|
||||
*/
|
||||
private static function get_key( $user_id, $endpoint ) {
|
||||
return "wpaw_rl_{$endpoint}_{$user_id}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if request is within rate limit.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param string $endpoint Endpoint name.
|
||||
* @param int $limit Max requests per window.
|
||||
* @param int $window Time window in seconds.
|
||||
* @return true|WP_Error True if allowed, WP_Error if rate limited.
|
||||
*/
|
||||
public static function check( $endpoint, $limit = null, $window = null ) {
|
||||
// Use default limits if not specified.
|
||||
if ( null === $limit || null === $window ) {
|
||||
$defaults = self::$default_limits[ $endpoint ] ?? self::$default_limits['default'];
|
||||
$limit = $defaults['limit'];
|
||||
$window = $defaults['window'];
|
||||
}
|
||||
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
// For non-authenticated users, use IP address as fallback.
|
||||
if ( 0 === $user_id ) {
|
||||
$user_id = self::get_client_ip();
|
||||
}
|
||||
|
||||
$key = self::get_key( $user_id, $endpoint );
|
||||
$count = (int) get_transient( $key );
|
||||
|
||||
if ( $count >= $limit ) {
|
||||
$retry_after = (int) get_transient( $key . '_expires' );
|
||||
if ( false === $retry_after ) {
|
||||
$retry_after = $window;
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'rate_limited',
|
||||
sprintf(
|
||||
// translators: %d is the number of seconds to wait.
|
||||
__( 'Too many requests. Please wait %d seconds.', 'wp-agentic-writer' ),
|
||||
$retry_after
|
||||
),
|
||||
[
|
||||
'status' => 429,
|
||||
'retry_after' => $retry_after,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
// Increment counter.
|
||||
if ( false === get_transient( $key ) ) {
|
||||
// First request in window - set expiration tracking.
|
||||
set_transient( $key, 1, $window );
|
||||
set_transient( $key . '_expires', $window, $window );
|
||||
} else {
|
||||
// Increment existing counter.
|
||||
set_transient( $key, $count + 1, $window );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current request count for an endpoint.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param string $endpoint Endpoint name.
|
||||
* @return int Current request count.
|
||||
*/
|
||||
public static function get_current_count( $endpoint ) {
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
if ( 0 === $user_id ) {
|
||||
$user_id = self::get_client_ip();
|
||||
}
|
||||
|
||||
$key = self::get_key( $user_id, $endpoint );
|
||||
$count = (int) get_transient( $key );
|
||||
|
||||
return false === $count ? 0 : $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remaining requests for an endpoint.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param string $endpoint Endpoint name.
|
||||
* @param int $limit Max requests per window.
|
||||
* @return int Remaining requests.
|
||||
*/
|
||||
public static function get_remaining( $endpoint, $limit = null ) {
|
||||
if ( null === $limit ) {
|
||||
$defaults = self::$default_limits[ $endpoint ] ?? self::$default_limits['default'];
|
||||
$limit = $defaults['limit'];
|
||||
}
|
||||
|
||||
$current = self::get_current_count( $endpoint );
|
||||
return max( 0, $limit - $current );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address for unauthenticated rate limiting.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @return string Client IP address.
|
||||
*/
|
||||
private static function get_client_ip() {
|
||||
$ip = '';
|
||||
|
||||
if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) {
|
||||
// Cloudflare.
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) );
|
||||
} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
|
||||
// X-Forwarded-For header (may contain multiple IPs).
|
||||
$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
|
||||
$ip = trim( $ips[0] );
|
||||
} elseif ( ! empty( $_SERVER['HTTP_X_REAL_IP'] ) ) {
|
||||
// X-Real-IP header.
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_REAL_IP'] ) );
|
||||
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
|
||||
// Direct IP.
|
||||
$ip = sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) );
|
||||
}
|
||||
|
||||
// Validate IP format.
|
||||
if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
|
||||
$ip = '0.0.0.0';
|
||||
}
|
||||
|
||||
return $ip;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset rate limit for a user/endpoint (for testing).
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param int $user_id User ID.
|
||||
* @param string $endpoint Endpoint name.
|
||||
* @return bool Success.
|
||||
*/
|
||||
public static function reset( $user_id, $endpoint ) {
|
||||
$key = self::get_key( $user_id, $endpoint );
|
||||
delete_transient( $key );
|
||||
delete_transient( $key . '_expires' );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add rate limit headers to response.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_REST_Response $response Response object.
|
||||
* @param string $endpoint Endpoint name.
|
||||
* @param int $limit Max requests per window.
|
||||
* @return WP_REST_Response Modified response with headers.
|
||||
*/
|
||||
public static function add_headers( $response, $endpoint, $limit = null ) {
|
||||
if ( null === $limit ) {
|
||||
$defaults = self::$default_limits[ $endpoint ] ?? self::$default_limits['default'];
|
||||
$limit = $defaults['limit'];
|
||||
}
|
||||
|
||||
$remaining = self::get_remaining( $endpoint, $limit );
|
||||
$current = self::get_current_count( $endpoint );
|
||||
|
||||
$response->header( 'X-RateLimit-Limit', $limit );
|
||||
$response->header( 'X-RateLimit-Remaining', $remaining );
|
||||
$response->header( 'X-RateLimit-Used', $current );
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
1403
includes/class-sidebar-helpers.php
Normal file
1403
includes/class-sidebar-helpers.php
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user