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.
7902 lines
270 KiB
PHP
7902 lines
270 KiB
PHP
<?php
|
|
/**
|
|
* Gutenberg Sidebar
|
|
*
|
|
* Registers the plugin sidebar in Gutenberg editor.
|
|
*
|
|
* @package WP_Agentic_Writer
|
|
*/
|
|
|
|
if (!defined("ABSPATH")) {
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Debug logging helper - logs only when SCRIPT_DEBUG is enabled.
|
|
*
|
|
* @param string $message Log message.
|
|
* @param mixed $data Optional data to log.
|
|
*/
|
|
function wpaw_debug_log($message, $data = null)
|
|
{
|
|
if (defined("SCRIPT_DEBUG") && SCRIPT_DEBUG) {
|
|
$prefix = "[WPAW Debug] ";
|
|
if (null === $data) {
|
|
error_log($prefix . $message);
|
|
} else {
|
|
error_log($prefix . $message . " " . wp_json_encode($data));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Class WP_Agentic_Writer_Gutenberg_Sidebar
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
class WP_Agentic_Writer_Gutenberg_Sidebar
|
|
{
|
|
/**
|
|
* Writing controller instance.
|
|
*
|
|
* @since 0.3.0
|
|
* @var WP_Agentic_Writer_Controller_Writing
|
|
*/
|
|
private $controller_writing;
|
|
|
|
/**
|
|
* Refinement controller instance.
|
|
*
|
|
* @since 0.3.0
|
|
* @var WP_Agentic_Writer_Controller_Refinement
|
|
*/
|
|
private $controller_refinement;
|
|
|
|
/**
|
|
* Image controller instance.
|
|
*
|
|
* @since 0.3.0
|
|
* @var WP_Agentic_Writer_Controller_Image
|
|
*/
|
|
private $controller_image;
|
|
|
|
/**
|
|
* SEO controller instance.
|
|
*
|
|
* @since 0.3.0
|
|
* @var WP_Agentic_Writer_Controller_Seo
|
|
*/
|
|
private $controller_seo;
|
|
|
|
/**
|
|
* Get singleton instance.
|
|
*
|
|
* @since 0.1.0
|
|
* @return WP_Agentic_Writer_Gutenberg_Sidebar
|
|
*/
|
|
public static function get_instance()
|
|
{
|
|
static $instance = null;
|
|
|
|
if (null === $instance) {
|
|
$instance = new self();
|
|
}
|
|
|
|
return $instance;
|
|
}
|
|
|
|
/**
|
|
* Constructor.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
private function __construct()
|
|
{
|
|
add_action("enqueue_block_editor_assets", [$this, "enqueue_assets"]);
|
|
add_action("enqueue_block_assets", [$this, "enqueue_block_assets"]);
|
|
add_action("rest_api_init", [$this, "register_rest_routes"]);
|
|
|
|
// Initialize controllers (lazy-load to avoid circular dependencies during early init).
|
|
add_action("init", [$this, "initialize_controllers"], 5);
|
|
}
|
|
|
|
/**
|
|
* Initialize controllers after plugins_loaded.
|
|
*
|
|
* @since 0.3.0
|
|
*/
|
|
public function initialize_controllers()
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-writing.php";
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-refinement.php";
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-image.php";
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-seo.php";
|
|
|
|
$this->controller_writing = new WP_Agentic_Writer_Controller_Writing(
|
|
$this,
|
|
);
|
|
$this->controller_refinement = new WP_Agentic_Writer_Controller_Refinement(
|
|
$this,
|
|
);
|
|
$this->controller_image = new WP_Agentic_Writer_Controller_Image($this);
|
|
$this->controller_seo = new WP_Agentic_Writer_Controller_Seo($this);
|
|
}
|
|
|
|
/**
|
|
* Enqueue block assets (inside the editor canvas iframe).
|
|
*
|
|
* @since 0.3.0
|
|
*/
|
|
public function enqueue_block_assets()
|
|
{
|
|
if (!is_admin()) {
|
|
return;
|
|
}
|
|
|
|
$editor_style_url = WP_AGENTIC_WRITER_URL . "assets/css/editor.css";
|
|
$editor_style_path = WP_AGENTIC_WRITER_DIR . "assets/css/editor.css";
|
|
|
|
wp_enqueue_style(
|
|
"wp-agentic-writer-editor",
|
|
$editor_style_url,
|
|
[],
|
|
file_exists($editor_style_path)
|
|
? filemtime($editor_style_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Enqueue sidebar assets.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
public function enqueue_assets()
|
|
{
|
|
// Check if Gutenberg is available.
|
|
if (!function_exists("register_block_type")) {
|
|
return;
|
|
}
|
|
|
|
// Check if we're in the block editor.
|
|
$current_screen = get_current_screen();
|
|
if (!$current_screen || !$current_screen->is_block_editor) {
|
|
return;
|
|
}
|
|
|
|
// Build script URL.
|
|
$script_url = WP_AGENTIC_WRITER_URL . "assets/js/dist/sidebar.js";
|
|
$style_url = WP_AGENTIC_WRITER_URL . "assets/css/agentic-sidebar.css";
|
|
$editor_style_url = WP_AGENTIC_WRITER_URL . "assets/css/editor.css";
|
|
$markdown_it_url =
|
|
WP_AGENTIC_WRITER_URL . "assets/js/vendor/markdown-it.min.js";
|
|
$dompurify_url =
|
|
WP_AGENTIC_WRITER_URL . "assets/js/vendor/purify.min.js";
|
|
$markdown_task_lists_url =
|
|
WP_AGENTIC_WRITER_URL .
|
|
"assets/js/vendor/markdown-it-task-lists.min.js";
|
|
|
|
// Enqueue markdown renderer and sanitizer.
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-markdown-it",
|
|
$markdown_it_url,
|
|
[],
|
|
"13.0.2",
|
|
true,
|
|
);
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-dompurify",
|
|
$dompurify_url,
|
|
[],
|
|
"3.0.8",
|
|
true,
|
|
);
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-markdown-task-lists",
|
|
$markdown_task_lists_url,
|
|
["wp-agentic-writer-markdown-it"],
|
|
"2.1.1",
|
|
true,
|
|
);
|
|
|
|
// Enqueue utility functions (loaded before main sidebar).
|
|
$utils_script_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/js/sidebar-utils.js";
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-sidebar-utils",
|
|
WP_AGENTIC_WRITER_URL . "assets/js/sidebar-utils.js",
|
|
[],
|
|
file_exists($utils_script_path)
|
|
? filemtime($utils_script_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
true,
|
|
);
|
|
|
|
// Enqueue sidebar script.
|
|
$script_path = WP_AGENTIC_WRITER_DIR . "assets/js/dist/sidebar.js";
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-sidebar",
|
|
$script_url,
|
|
[
|
|
"wp-plugins",
|
|
"wp-edit-post",
|
|
"wp-element",
|
|
"wp-components",
|
|
"wp-compose",
|
|
"wp-data",
|
|
"wp-i18n",
|
|
"wp-blocks",
|
|
"wp-agentic-writer-markdown-it",
|
|
"wp-agentic-writer-dompurify",
|
|
"wp-agentic-writer-markdown-task-lists",
|
|
"wp-agentic-writer-sidebar-utils",
|
|
],
|
|
file_exists($script_path)
|
|
? filemtime($script_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
true,
|
|
);
|
|
|
|
$block_toolbar_script_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/js/block-refine.js";
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-block-chat-mention",
|
|
WP_AGENTIC_WRITER_URL . "assets/js/block-refine.js",
|
|
[
|
|
"wp-block-editor",
|
|
"wp-components",
|
|
"wp-compose",
|
|
"wp-data",
|
|
"wp-element",
|
|
"wp-hooks",
|
|
"wp-i18n",
|
|
],
|
|
file_exists($block_toolbar_script_path)
|
|
? filemtime($block_toolbar_script_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
true,
|
|
);
|
|
|
|
// Enqueue image block toolbar script.
|
|
$block_image_script_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/js/block-image-generate.js";
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-block-image-generate",
|
|
WP_AGENTIC_WRITER_URL . "assets/js/block-image-generate.js",
|
|
[
|
|
"wp-block-editor",
|
|
"wp-components",
|
|
"wp-compose",
|
|
"wp-data",
|
|
"wp-element",
|
|
"wp-hooks",
|
|
"wp-i18n",
|
|
],
|
|
file_exists($block_image_script_path)
|
|
? filemtime($block_image_script_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
true,
|
|
);
|
|
|
|
// Enqueue image modal script.
|
|
$image_modal_script_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/js/image-modal.js";
|
|
wp_enqueue_script(
|
|
"wp-agentic-writer-image-modal",
|
|
WP_AGENTIC_WRITER_URL . "assets/js/image-modal.js",
|
|
["wp-components", "wp-element", "wp-data", "wp-block-editor"],
|
|
file_exists($image_modal_script_path)
|
|
? filemtime($image_modal_script_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
true,
|
|
);
|
|
|
|
// Enqueue Agentic Design System tokens (Stitch — Phase 0).
|
|
$tokens_style_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/css/agentic-tokens.css";
|
|
wp_enqueue_style(
|
|
"wp-agentic-writer-tokens",
|
|
WP_AGENTIC_WRITER_URL . "assets/css/agentic-tokens.css",
|
|
[],
|
|
file_exists($tokens_style_path)
|
|
? filemtime($tokens_style_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
);
|
|
|
|
// Enqueue sidebar styles.
|
|
$style_path = WP_AGENTIC_WRITER_DIR . "assets/css/agentic-sidebar.css";
|
|
wp_enqueue_style(
|
|
"wp-agentic-writer-sidebar",
|
|
$style_url,
|
|
["wp-agentic-writer-tokens"],
|
|
file_exists($style_path)
|
|
? filemtime($style_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
);
|
|
|
|
// Enqueue sidebar dark theme overrides (Stitch design system).
|
|
$dark_style_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/css/agentic-sidebar-dark.css";
|
|
wp_enqueue_style(
|
|
"wp-agentic-writer-sidebar-dark",
|
|
WP_AGENTIC_WRITER_URL . "assets/css/agentic-sidebar-dark.css",
|
|
["wp-agentic-writer-sidebar"],
|
|
file_exists($dark_style_path)
|
|
? filemtime($dark_style_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
);
|
|
|
|
// Enqueue agentic components styles.
|
|
$components_style_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/css/agentic-components.css";
|
|
$components_style_url =
|
|
WP_AGENTIC_WRITER_URL . "assets/css/agentic-components.css";
|
|
wp_enqueue_style(
|
|
"wp-agentic-writer-components",
|
|
$components_style_url,
|
|
[],
|
|
file_exists($components_style_path)
|
|
? filemtime($components_style_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
);
|
|
|
|
// Enqueue workflow styles.
|
|
$workflow_style_path =
|
|
WP_AGENTIC_WRITER_DIR . "assets/css/agentic-workflow.css";
|
|
$workflow_style_url =
|
|
WP_AGENTIC_WRITER_URL . "assets/css/agentic-workflow.css";
|
|
wp_enqueue_style(
|
|
"wp-agentic-writer-workflow",
|
|
$workflow_style_url,
|
|
[],
|
|
file_exists($workflow_style_path)
|
|
? filemtime($workflow_style_path)
|
|
: WP_AGENTIC_WRITER_VERSION,
|
|
);
|
|
|
|
// Removed redundant editor styles enqueue from here, moved to enqueue_block_assets
|
|
|
|
// Get current post ID.
|
|
$post_id = isset($_GET["post"]) ? intval($_GET["post"]) : 0;
|
|
if (!$post_id) {
|
|
$post_id = get_the_ID();
|
|
}
|
|
if (!$post_id) {
|
|
$post_id = 0;
|
|
}
|
|
|
|
// Get settings for JS.
|
|
$settings = $this->get_settings_for_js();
|
|
|
|
// Health check: verify DB table and API key exist
|
|
$health = $this->run_health_check();
|
|
|
|
// Localize script with data.
|
|
$data = [
|
|
"apiUrl" => rest_url("wp-agentic-writer/v1"),
|
|
"nonce" => wp_create_nonce("wp_rest"),
|
|
"postId" => $post_id,
|
|
"settings" => $settings,
|
|
"version" => WP_AGENTIC_WRITER_VERSION,
|
|
"debug" => defined("SCRIPT_DEBUG") && SCRIPT_DEBUG,
|
|
"pluginUrl" => plugin_dir_url(dirname(__FILE__)),
|
|
"health" => $health,
|
|
];
|
|
|
|
wp_localize_script(
|
|
"wp-agentic-writer-sidebar",
|
|
"wpAgenticWriter",
|
|
$data,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Run health check for sidebar initialization.
|
|
*
|
|
* @since 0.2.4
|
|
* @return array Health status.
|
|
*/
|
|
private function run_health_check()
|
|
{
|
|
global $wpdb;
|
|
$table_name = $wpdb->prefix . "wpaw_conversations";
|
|
$table_exists =
|
|
$wpdb->get_var(
|
|
$wpdb->prepare("SHOW TABLES LIKE %s", $table_name),
|
|
) === $table_name;
|
|
|
|
$settings = get_option("wp_agentic_writer_settings", []);
|
|
$has_api_key = !empty($settings["openrouter_api_key"]);
|
|
|
|
$issues = [];
|
|
if (!$table_exists) {
|
|
$issues[] = [
|
|
"type" => "db_table_missing",
|
|
"message" =>
|
|
"Conversation table not found. Please deactivate and reactivate the plugin.",
|
|
];
|
|
}
|
|
if (!$has_api_key) {
|
|
$issues[] = [
|
|
"type" => "no_api_key",
|
|
"message" =>
|
|
"API key not configured. Add your OpenRouter key in settings.",
|
|
"actionUrl" => admin_url(
|
|
"options-general.php?page=wp-agentic-writer-settings",
|
|
),
|
|
"actionLabel" => "Open Settings",
|
|
];
|
|
}
|
|
|
|
return [
|
|
"ok" => empty($issues),
|
|
"issues" => $issues,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get settings for JavaScript.
|
|
*
|
|
* @since 0.1.0
|
|
* @return array Settings.
|
|
*/
|
|
private function get_settings_for_js()
|
|
{
|
|
$settings = get_option("wp_agentic_writer_settings", []);
|
|
|
|
// Don't expose API key to frontend.
|
|
unset($settings["openrouter_api_key"]);
|
|
|
|
// Ensure all required keys exist with defaults from model registry.
|
|
$defaults = [
|
|
"chat_model" => WPAW_Model_Registry::get_default_model("chat"),
|
|
"clarity_model" => WPAW_Model_Registry::get_default_model(
|
|
"clarity",
|
|
),
|
|
"planning_model" => WPAW_Model_Registry::get_default_model(
|
|
"planning",
|
|
),
|
|
"writing_model" => WPAW_Model_Registry::get_default_model(
|
|
"writing",
|
|
),
|
|
"refinement_model" => WPAW_Model_Registry::get_default_model(
|
|
"refinement",
|
|
),
|
|
"image_model" => WPAW_Model_Registry::get_default_model("image"),
|
|
"web_search_enabled" => false,
|
|
"search_engine" => "auto",
|
|
"search_depth" => "medium",
|
|
"cost_tracking_enabled" => true,
|
|
"monthly_budget" => 600,
|
|
"settings_url" => admin_url(
|
|
"options-general.php?page=wp-agentic-writer-settings",
|
|
),
|
|
"preferred_languages" => ["auto", "English", "Indonesian"],
|
|
"custom_languages" => [],
|
|
];
|
|
|
|
return wp_parse_args($settings, $defaults);
|
|
}
|
|
|
|
/**
|
|
* Register REST API routes.
|
|
*
|
|
* @since 0.1.0
|
|
*/
|
|
public function register_rest_routes()
|
|
{
|
|
// Get models endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/models", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_models"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Refresh models endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/models/refresh", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_refresh_models"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Chat endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/chat", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_chat_request"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Clear chat context endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/clear-context", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_clear_context"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
// Chat history endpoint (deprecated - for backward compatibility only).
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/chat-history/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_chat_history"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Conversation session endpoint (canonical for chat hydration).
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversation/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_conversation_by_post"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Post config endpoints.
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/post-config/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_post_config"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/post-config/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_update_post_config"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Generate plan endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/generate-plan", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_generate_plan"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
// Revise plan endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/revise-plan", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_revise_plan"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Execute article endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/execute-article", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_writing, "handle_execute_article"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
// Reformat blocks endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/reformat-blocks", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_writing, "handle_reformat_blocks"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Regenerate block endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/regenerate-block", [
|
|
"methods" => "POST",
|
|
"callback" => [
|
|
$this->controller_refinement,
|
|
"handle_regenerate_block",
|
|
],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Check clarity endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/check-clarity", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_check_clarity"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Block refine endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/refine-block", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_refinement, "handle_block_refine"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Chat-based block refinement endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/refine-from-chat", [
|
|
"methods" => "POST",
|
|
"callback" => [
|
|
$this->controller_refinement,
|
|
"handle_refine_from_chat",
|
|
],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Section block mapping endpoints.
|
|
register_rest_route("wp-agentic-writer/v1", "/section-blocks", [
|
|
"methods" => "POST",
|
|
"callback" => [
|
|
$this->controller_writing,
|
|
"handle_save_section_blocks",
|
|
],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/section-blocks/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [
|
|
$this->controller_writing,
|
|
"handle_get_section_blocks",
|
|
],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Get cost tracking data endpoint.
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/cost-tracking/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_cost_tracking"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// SEO audit endpoint.
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/seo-audit/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this->controller_seo, "handle_seo_audit"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Generate meta description endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/generate-meta", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_seo, "handle_generate_meta"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Suggest keywords endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/suggest-keywords", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_seo, "handle_suggest_keywords"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Summarize context endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/summarize-context", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_seo, "handle_summarize_context"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Multi-pass refinement endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/refine-multi-pass", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_seo, "handle_refine_multi_pass"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Article-wide refinement endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/refine-article", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_seo, "handle_refine_article"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// GEO scoring endpoint.
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/geo-score/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this->controller_seo, "handle_geo_score"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Proactive suggestions endpoint (idle analysis)
|
|
register_rest_route("wp-agentic-writer/v1", "/suggest-improvements", [
|
|
"methods" => "POST",
|
|
"callback" => [
|
|
$this->controller_seo,
|
|
"handle_suggest_improvements",
|
|
],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Detect intent endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/detect-intent", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_detect_intent"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Image generation endpoints.
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/image-recommendations/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [
|
|
$this->controller_image,
|
|
"handle_get_image_recommendations",
|
|
],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
register_rest_route("wp-agentic-writer/v1", "/generate-image", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_image, "handle_generate_image"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
register_rest_route("wp-agentic-writer/v1", "/commit-image", [
|
|
"methods" => "POST",
|
|
"callback" => [$this->controller_image, "handle_commit_image"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Writing state persistence endpoint.
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/writing-state/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_writing_state"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/writing-state/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_save_writing_state"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Generate title endpoint (uses WP 7.0 AI Client when available).
|
|
register_rest_route("wp-agentic-writer/v1", "/generate-title", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_generate_title"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Refine title endpoint (instruction-driven rewrite from chat mention @title).
|
|
register_rest_route("wp-agentic-writer/v1", "/refine-title", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_refine_title"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Generate excerpt endpoint (uses WP 7.0 AI Client when available).
|
|
register_rest_route("wp-agentic-writer/v1", "/generate-excerpt", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_generate_excerpt"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// AI capabilities status endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/ai-capabilities", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_ai_capabilities"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Brave Search endpoint for research.
|
|
register_rest_route("wp-agentic-writer/v1", "/search", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_search"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Fetch web content endpoint for research.
|
|
register_rest_route("wp-agentic-writer/v1", "/fetch-content", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_fetch_content"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Research summary endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/research-summary", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_research_summary"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// MEMANTO status endpoint.
|
|
register_rest_route("wp-agentic-writer/v1", "/memanto/status", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_memanto_status"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// MEMANTO recall endpoint — recent memories for a post.
|
|
register_rest_route("wp-agentic-writer/v1", "/memanto/recall", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_memanto_recall"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// MEMANTO session restore endpoint — for cross-session restore on editor load.
|
|
register_rest_route("wp-agentic-writer/v1", "/memanto/restore", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_memanto_restore"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// MEMANTO user preferences endpoint — for new post config carry-over.
|
|
register_rest_route("wp-agentic-writer/v1", "/memanto/preferences", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_memanto_preferences"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
|
|
// Conversation sessions endpoints.
|
|
register_rest_route("wp-agentic-writer/v1", "/conversations", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_conversations"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/post/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_conversations"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route("wp-agentic-writer/v1", "/conversations", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_create_conversation"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/(?P<session_id>[a-zA-Z0-9]+)",
|
|
[
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_conversation"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/(?P<session_id>[a-zA-Z0-9]+)",
|
|
[
|
|
"methods" => "PUT",
|
|
"callback" => [$this, "handle_update_conversation"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/(?P<session_id>[a-zA-Z0-9]+)",
|
|
[
|
|
"methods" => "DELETE",
|
|
"callback" => [$this, "handle_delete_conversation"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/(?P<session_id>[a-zA-Z0-9]+)/messages",
|
|
[
|
|
"methods" => ["POST", "PUT"],
|
|
"callback" => [$this, "handle_update_conversation_messages"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/(?P<session_id>[a-zA-Z0-9]+)/link-post",
|
|
[
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_link_conversation_to_post"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Session edit-lock endpoints (multi-tab safety).
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/(?P<session_id>[a-zA-Z0-9]+)/lock",
|
|
[
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_session_lock"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/conversations/(?P<session_id>[a-zA-Z0-9]+)/lock",
|
|
[
|
|
"methods" => "DELETE",
|
|
"callback" => [$this, "handle_session_unlock"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// Legacy migration endpoint for converting post meta chat history to sessions
|
|
register_rest_route(
|
|
"wp-agentic-writer/v1",
|
|
"/migrate-chat-history/(?P<post_id>\d+)",
|
|
[
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_migrate_chat_history"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
],
|
|
);
|
|
|
|
// User preferences endpoints (per-user settings)
|
|
register_rest_route("wp-agentic-writer/v1", "/user-preferences", [
|
|
"methods" => "GET",
|
|
"callback" => [$this, "handle_get_user_preferences"],
|
|
"permission_callback" => "__return_true",
|
|
]);
|
|
register_rest_route("wp-agentic-writer/v1", "/user-preferences", [
|
|
"methods" => "POST",
|
|
"callback" => [$this, "handle_save_user_preferences"],
|
|
"permission_callback" => [$this, "check_permissions"],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Handle get writing state request.
|
|
*
|
|
* @since 0.2.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_get_writing_state($request)
|
|
{
|
|
$post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0;
|
|
if ($post_id <= 0) {
|
|
return new WP_Error(
|
|
"invalid_post",
|
|
__("Invalid post ID.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
// Authorization: Check if user can edit this specific post.
|
|
if (!current_user_can("edit_post", $post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to access this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$state = [
|
|
"status" =>
|
|
get_post_meta($post_id, "_wpaw_writing_status", true) ?: "idle",
|
|
"current_section_index" =>
|
|
(int) get_post_meta($post_id, "_wpaw_current_section", true) ?:
|
|
0,
|
|
"sections_written" =>
|
|
get_post_meta($post_id, "_wpaw_sections_written", true) ?: [],
|
|
"last_updated" =>
|
|
get_post_meta($post_id, "_wpaw_writing_state_updated", true) ?:
|
|
null,
|
|
"plan_id" => get_post_meta($post_id, "_wpaw_plan_id", true) ?: null,
|
|
"resume_token" =>
|
|
get_post_meta($post_id, "_wpaw_resume_token", true) ?: null,
|
|
];
|
|
|
|
return new WP_REST_Response($state, 200);
|
|
}
|
|
|
|
/**
|
|
* Handle save writing state request.
|
|
*
|
|
* @since 0.2.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_save_writing_state($request)
|
|
{
|
|
$post_id = isset($request["post_id"]) ? (int) $request["post_id"] : 0;
|
|
if ($post_id <= 0) {
|
|
return new WP_Error(
|
|
"invalid_post",
|
|
__("Invalid post ID.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
// Authorization: Check if user can edit this specific post.
|
|
if (!current_user_can("edit_post", $post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to modify this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$params = $request->get_json_params();
|
|
|
|
// Validate status against allowed values.
|
|
$allowed_statuses = [
|
|
"idle",
|
|
"in_progress",
|
|
"paused",
|
|
"completed",
|
|
"failed",
|
|
];
|
|
$status = sanitize_text_field($params["status"] ?? "idle");
|
|
if (!in_array($status, $allowed_statuses, true)) {
|
|
$status = "idle";
|
|
}
|
|
|
|
// Save writing status
|
|
update_post_meta($post_id, "_wpaw_writing_status", $status);
|
|
|
|
// Save current section index
|
|
$section_index = (int) ($params["current_section_index"] ?? 0);
|
|
update_post_meta($post_id, "_wpaw_current_section", $section_index);
|
|
|
|
// Save sections written array
|
|
$sections_written = is_array($params["sections_written"] ?? null)
|
|
? array_map("sanitize_text_field", $params["sections_written"])
|
|
: [];
|
|
update_post_meta($post_id, "_wpaw_sections_written", $sections_written);
|
|
|
|
// Save plan ID
|
|
$plan_id = sanitize_text_field($params["plan_id"] ?? "");
|
|
update_post_meta($post_id, "_wpaw_plan_id", $plan_id);
|
|
|
|
// Save resume token
|
|
$resume_token = sanitize_text_field($params["resume_token"] ?? "");
|
|
update_post_meta($post_id, "_wpaw_resume_token", $resume_token);
|
|
|
|
// Update timestamp
|
|
update_post_meta(
|
|
$post_id,
|
|
"_wpaw_writing_state_updated",
|
|
current_time("mysql"),
|
|
);
|
|
|
|
$state = [
|
|
"status" => $status,
|
|
"current_section_index" => $section_index,
|
|
"sections_written" => $sections_written,
|
|
"last_updated" => current_time("mysql"),
|
|
"plan_id" => $plan_id,
|
|
];
|
|
|
|
return new WP_REST_Response($state, 200);
|
|
}
|
|
|
|
/**
|
|
* Check permissions.
|
|
*
|
|
* @since 0.1.0
|
|
* @return bool True if user has permission.
|
|
*/
|
|
public function check_permissions()
|
|
{
|
|
return current_user_can("edit_posts");
|
|
}
|
|
|
|
/**
|
|
* Check post-specific edit permissions.
|
|
*
|
|
* @since 0.1.3
|
|
* @param int $post_id Post ID to check.
|
|
* @return bool True if user can edit the post.
|
|
*/
|
|
public function check_post_permission($post_id)
|
|
{
|
|
if ($post_id <= 0) {
|
|
return false;
|
|
}
|
|
return current_user_can("edit_post", $post_id);
|
|
}
|
|
|
|
/**
|
|
* Resolve session ID from request, or auto-create a post-linked session.
|
|
*
|
|
* @param string $session_id Existing session ID from request.
|
|
* @param int $post_id Post ID.
|
|
* @return string
|
|
*/
|
|
public function resolve_or_create_session_id($session_id, $post_id)
|
|
{
|
|
$session_id = sanitize_text_field((string) $session_id);
|
|
if ("" !== $session_id) {
|
|
return $session_id;
|
|
}
|
|
|
|
$post_id = (int) $post_id;
|
|
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
|
if ($post_id <= 0) {
|
|
$created_unassigned = $manager->create_session([
|
|
"post_id" => 0,
|
|
"title" => "Unassigned Session - " . current_time("Y-m-d H:i"),
|
|
]);
|
|
return is_wp_error($created_unassigned)
|
|
? ""
|
|
: (string) $created_unassigned;
|
|
}
|
|
|
|
$existing = $manager->get_session_by_post_id($post_id);
|
|
if ($existing && !empty($existing["session_id"])) {
|
|
return (string) $existing["session_id"];
|
|
}
|
|
|
|
$created = $manager->create_session([
|
|
"post_id" => $post_id,
|
|
"title" => "Post " . $post_id . " Session",
|
|
]);
|
|
return is_wp_error($created) ? "" : (string) $created;
|
|
}
|
|
|
|
/**
|
|
* Build provider metadata for responses.
|
|
*
|
|
* @since 0.1.4
|
|
* @param WPAW_Provider_Selection_Result $provider_result Provider selection result.
|
|
* @param string $model Model identifier used.
|
|
* @return array Provider metadata.
|
|
*/
|
|
public function build_provider_metadata($provider_result, $model = "")
|
|
{
|
|
$actual_provider = $provider_result->actual_provider ?? "unknown";
|
|
|
|
return [
|
|
"provider" => $actual_provider,
|
|
"selected_provider" =>
|
|
$provider_result->selected_provider ?? $actual_provider,
|
|
"fallback_used" => !empty($provider_result->fallback_used),
|
|
"warnings" => $provider_result->warnings ?? [],
|
|
"model" => $model,
|
|
"byok_managed_by" =>
|
|
"openrouter" === $actual_provider ? "openrouter" : "",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get a provider model label without assuming provider-specific helpers exist.
|
|
*
|
|
* @since 0.2.2
|
|
* @param object $provider Provider instance.
|
|
* @param string $fallback Fallback model label.
|
|
* @return string
|
|
*/
|
|
public function get_provider_execution_model(
|
|
$provider,
|
|
$fallback = "execution",
|
|
) {
|
|
if (
|
|
is_object($provider) &&
|
|
method_exists($provider, "get_execution_model")
|
|
) {
|
|
return (string) $provider->get_execution_model();
|
|
}
|
|
|
|
return $fallback;
|
|
}
|
|
|
|
/**
|
|
* Track AI cost with full metadata.
|
|
*
|
|
* This helper ensures all cost tracking includes provider, session, and status
|
|
* metadata consistently. Use this instead of raw do_action calls.
|
|
*
|
|
* @since 0.2.0
|
|
* @param int $post_id Post ID.
|
|
* @param string $model Model used.
|
|
* @param string $action Action type (chat, planning, execution, etc).
|
|
* @param int $input_tokens Input token count.
|
|
* @param int $output_tokens Output token count.
|
|
* @param float $cost Cost in USD.
|
|
* @param mixed $provider_result Provider selection result or provider name string.
|
|
* @param string $session_id Session ID (optional).
|
|
* @param string $status Status (success, error) (optional, defaults to 'success').
|
|
*/
|
|
public function track_ai_cost(
|
|
$post_id,
|
|
$model,
|
|
$action,
|
|
$input_tokens,
|
|
$output_tokens,
|
|
$cost,
|
|
$provider_result,
|
|
$session_id = "",
|
|
$status = "success",
|
|
) {
|
|
// Handle both provider result objects and plain strings
|
|
if (
|
|
is_object($provider_result) &&
|
|
isset($provider_result->actual_provider)
|
|
) {
|
|
$actual_provider = $provider_result->actual_provider;
|
|
} elseif (is_string($provider_result)) {
|
|
$actual_provider = $provider_result;
|
|
} else {
|
|
$actual_provider = "unknown";
|
|
}
|
|
|
|
do_action(
|
|
"wp_aw_after_api_request",
|
|
$post_id,
|
|
$model,
|
|
$action,
|
|
$input_tokens,
|
|
$output_tokens,
|
|
$cost,
|
|
$actual_provider,
|
|
$session_id,
|
|
$status,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle chat request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_chat_request($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$messages = $params["messages"] ?? [];
|
|
$post_id = $params["postId"] ?? 0;
|
|
$type = $params["type"] ?? "planning";
|
|
$stream = !empty($params["stream"]);
|
|
$session_id = $this->resolve_or_create_session_id(
|
|
$params["sessionId"] ?? "",
|
|
$post_id,
|
|
);
|
|
|
|
// Check post permission if post_id is provided.
|
|
if ($post_id > 0 && !$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to access this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$post_config = $this->resolve_post_config_from_request(
|
|
$params,
|
|
$post_id,
|
|
);
|
|
$post_config_context = $this->build_post_config_context($post_config);
|
|
|
|
// Detect language from user's last message for real-time response matching.
|
|
$last_user_message = $this->get_last_user_message($messages);
|
|
$detected_from_message = $this->detect_language_from_text(
|
|
$last_user_message,
|
|
);
|
|
$stored_language = get_post_meta(
|
|
$post_id,
|
|
"_wpaw_detected_language",
|
|
true,
|
|
);
|
|
$effective_language = $this->resolve_language_preference(
|
|
$post_config,
|
|
$detected_from_message ?: $stored_language,
|
|
);
|
|
|
|
// Extract focus keyword for context anchoring.
|
|
$focus_keyword = "";
|
|
if (!empty($post_config["focus_keyword"])) {
|
|
$focus_keyword = sanitize_text_field($post_config["focus_keyword"]);
|
|
} elseif (!empty($post_config["seo_focus_keyword"])) {
|
|
$focus_keyword = sanitize_text_field(
|
|
$post_config["seo_focus_keyword"],
|
|
);
|
|
} elseif ($post_id > 0) {
|
|
$focus_keyword = get_post_meta(
|
|
$post_id,
|
|
"_wpaw_focus_keyword",
|
|
true,
|
|
);
|
|
}
|
|
|
|
// Build focus keyword instruction for chat.
|
|
$focus_keyword_instruction = "";
|
|
if (!empty($focus_keyword)) {
|
|
$focus_keyword_instruction = "
|
|
CONTEXT ANCHOR: The user is working on an article about \"{$focus_keyword}\".
|
|
Keep your responses relevant to this primary topic. If the conversation drifts, gently guide it back to \"{$focus_keyword}\".
|
|
|
|
At the END of your response, if you identify a good focus keyword from the discussion, suggest it in this format:
|
|
**Focus Keyword Suggestion:** [your suggested keyword]
|
|
";
|
|
}
|
|
|
|
$language_instruction = $this->build_language_instruction(
|
|
$effective_language,
|
|
"chat responses",
|
|
);
|
|
$system_prompt = "You are a helpful writing assistant. Answer clearly, with concise structure and practical suggestions.
|
|
{$focus_keyword_instruction}
|
|
CRITICAL LANGUAGE REQUIREMENT:
|
|
{$language_instruction}
|
|
{$post_config_context}";
|
|
|
|
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
|
|
$context_package = $context_builder->build_system_message(
|
|
"chat",
|
|
$session_id,
|
|
$post_id,
|
|
array_merge($params, [
|
|
"messages" => $messages,
|
|
"postConfig" => $post_config,
|
|
"latestUserMessage" => $last_user_message,
|
|
]),
|
|
);
|
|
|
|
// OpenRouter is stateless; send only compact saved context plus the latest turn.
|
|
$messages = [];
|
|
if ("" !== trim((string) $last_user_message)) {
|
|
$messages[] = [
|
|
"role" => "user",
|
|
"content" => $last_user_message,
|
|
];
|
|
}
|
|
|
|
$messages = $this->prepend_system_prompt($messages, $system_prompt);
|
|
if (!empty($context_package["message"])) {
|
|
array_splice($messages, 1, 0, [$context_package["message"]]);
|
|
}
|
|
|
|
// Get provider for this task type with selection metadata.
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
$type,
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$provider_warnings = $provider_result->warnings;
|
|
|
|
if ($stream) {
|
|
$web_search_options = $this->get_web_search_options($post_config);
|
|
$this->stream_chat_request(
|
|
$messages,
|
|
$post_id,
|
|
$type,
|
|
$web_search_options,
|
|
$session_id,
|
|
);
|
|
exit();
|
|
}
|
|
|
|
// Send chat request.
|
|
$response = $provider->chat($messages, [], $type);
|
|
|
|
if (is_wp_error($response)) {
|
|
return new WP_Error("chat_error", $response->get_error_message(), [
|
|
"status" => 500,
|
|
]);
|
|
}
|
|
|
|
// MEMANTO: Remember user message (fire-and-forget, never blocks).
|
|
do_action(
|
|
"wpaw_memanto_user_message",
|
|
$session_id,
|
|
$last_user_message,
|
|
$post_id,
|
|
);
|
|
|
|
// Track cost with provider and session metadata.
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"chat",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$response["cost"] ?? 0,
|
|
$provider_result,
|
|
$session_id,
|
|
"success",
|
|
);
|
|
|
|
// Include provider metadata in response (DoD Provider Transparency contract).
|
|
$response["provider"] = $provider_result->actual_provider;
|
|
$response["selected_provider"] = $provider_result->selected_provider;
|
|
$response["fallback_used"] = $provider_result->fallback_used;
|
|
$response["warnings"] = $provider_warnings;
|
|
$response["session_id"] = $session_id;
|
|
$response["context_audit"] = $context_package["audit"] ?? [];
|
|
// Also include nested form for consistency with other AI endpoints.
|
|
$response["provider_metadata"] = $this->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
);
|
|
|
|
if (!empty($response["content"])) {
|
|
// Storage: Persist to session table via Context Service only.
|
|
// Legacy _wpaw_chat_history post meta is deprecated and no longer written.
|
|
if (!empty($session_id)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context_service->add_message($session_id, [
|
|
"role" => "user",
|
|
"content" => $last_user_message,
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
$context_service->add_message($session_id, [
|
|
"role" => "assistant",
|
|
"content" => $response["content"],
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// MEMANTO: Fire session start on first chat interaction.
|
|
do_action(
|
|
"wpaw_memanto_session_start",
|
|
$session_id,
|
|
$post_id,
|
|
get_current_user_id(),
|
|
);
|
|
|
|
return new WP_REST_Response($response, 200);
|
|
}
|
|
|
|
/**
|
|
* Stream chat request response.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $messages Chat messages.
|
|
* @param int $post_id Post ID.
|
|
* @param string $type Chat type.
|
|
* @param array $web_search_options Web search options.
|
|
* @param string $session_id Session ID for context persistence.
|
|
* @return void
|
|
*/
|
|
private function stream_chat_request(
|
|
$messages,
|
|
$post_id,
|
|
$type,
|
|
$web_search_options = [],
|
|
$session_id = "",
|
|
) {
|
|
header("Content-Type: text/event-stream");
|
|
header("Cache-Control: no-cache");
|
|
header("X-Accel-Buffering: no");
|
|
|
|
// Aggressively disable ALL output buffering layers (WordPress nests multiple).
|
|
@ini_set("output_buffering", "Off");
|
|
@ini_set("zlib.output_compression", false);
|
|
while (ob_get_level() > 0) {
|
|
ob_end_flush();
|
|
}
|
|
flush();
|
|
|
|
// Initialize streaming state variables.
|
|
$accumulated_content = "";
|
|
$chunks_emitted = 0;
|
|
$total_cost = 0;
|
|
$last_user_message = $this->get_last_user_message($messages);
|
|
|
|
// MEMANTO: Notify session start.
|
|
do_action(
|
|
"wpaw_memanto_session_start",
|
|
$session_id,
|
|
$post_id,
|
|
get_current_user_id(),
|
|
);
|
|
|
|
// Get provider with selection metadata for transparency.
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
$type,
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$provider_warnings = $provider_result->warnings;
|
|
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "provider",
|
|
"provider" => $provider_result->actual_provider,
|
|
"selectedProvider" => $provider_result->selected_provider,
|
|
"fallback_used" => $provider_result->fallback_used,
|
|
"byok_managed_by" =>
|
|
"openrouter" === $provider_result->actual_provider
|
|
? "openrouter"
|
|
: "",
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
$this->maybe_inject_brave_search(
|
|
$messages,
|
|
$provider,
|
|
$web_search_options,
|
|
);
|
|
|
|
$response = $provider->chat_stream(
|
|
$messages,
|
|
$web_search_options,
|
|
$type,
|
|
function ($chunk, $is_complete, $full_content) use (
|
|
&$accumulated_content,
|
|
&$chunks_emitted,
|
|
) {
|
|
$accumulated_content = $full_content;
|
|
if ("" !== $chunk) {
|
|
$chunks_emitted++;
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "conversational_stream",
|
|
"content" => $accumulated_content,
|
|
]) .
|
|
"\n\n";
|
|
if (ob_get_level() > 0) {
|
|
ob_end_flush();
|
|
}
|
|
flush();
|
|
}
|
|
},
|
|
);
|
|
|
|
// Fallback: if streaming produced no chunks but we have accumulated content, emit it now.
|
|
if (
|
|
0 === $chunks_emitted &&
|
|
!is_wp_error($response) &&
|
|
!empty($response["content"])
|
|
) {
|
|
$accumulated_content = $response["content"];
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "conversational_stream",
|
|
"content" => $accumulated_content,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
if (is_wp_error($response)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $response->get_error_message(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
if ("" === trim((string) $accumulated_content)) {
|
|
error_log(
|
|
"WPAW Stream Debug: provider returned empty chat content",
|
|
);
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => __(
|
|
"The provider returned an empty chat response.",
|
|
"wp-agentic-writer",
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
$total_cost = $response["cost"] ?? 0;
|
|
|
|
// Debug: Log chat cost tracking (only when WP_DEBUG is on).
|
|
wpaw_debug_log("Tracking chat cost", [
|
|
"post_id" => $post_id,
|
|
"model" => $response["model"] ?? "unknown",
|
|
"type" => $type,
|
|
"cost" => $total_cost,
|
|
]);
|
|
|
|
// Track cost with provider and session metadata.
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"chat",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$total_cost,
|
|
$provider_result,
|
|
$session_id,
|
|
"success",
|
|
);
|
|
|
|
if (!empty($accumulated_content)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "conversational",
|
|
"content" => $accumulated_content,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
// Storage: Persist to session table via Context Service only.
|
|
// Legacy _wpaw_chat_history post meta is deprecated and no longer written.
|
|
if (!empty($session_id)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context_service->add_message($session_id, [
|
|
"role" => "user",
|
|
"content" => $last_user_message,
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
$context_service->add_message($session_id, [
|
|
"role" => "assistant",
|
|
"content" => $accumulated_content,
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
}
|
|
|
|
// MEMANTO: Remember user message from chat.
|
|
do_action(
|
|
"wpaw_memanto_user_message",
|
|
$session_id,
|
|
$last_user_message,
|
|
$post_id,
|
|
);
|
|
}
|
|
|
|
// Send provider transparency metadata in completion event.
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "complete",
|
|
"totalCost" => $total_cost,
|
|
"session_id" => $session_id,
|
|
"provider" => $provider_result->actual_provider,
|
|
"fallback_used" => $provider_result->fallback_used,
|
|
"warnings" => $provider_warnings,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
/**
|
|
* Clear chat context for a post.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_clear_context($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Chat($this);
|
|
return $ctrl->handle_clear_context($request);
|
|
}
|
|
|
|
/**
|
|
* Get chat history for a post (deprecated compatibility endpoint).
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.2.0 Use /wp-agentic-writer/v1/conversation/{post_id} instead.
|
|
* This endpoint reads from conversation sessions via migration.
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_get_chat_history($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Chat($this);
|
|
return $ctrl->handle_get_chat_history($request);
|
|
}
|
|
|
|
/**
|
|
* Handle get conversation by post ID request (canonical endpoint).
|
|
*
|
|
* @since 0.2.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_get_conversation_by_post($request)
|
|
{
|
|
$post_id = intval($request["post_id"] ?? 0);
|
|
if ($post_id <= 0) {
|
|
return new WP_Error(
|
|
"invalid_post",
|
|
__("Invalid post ID.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
if (!$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to access this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
|
$session = $manager->get_session_by_post_id($post_id);
|
|
|
|
if (!$session) {
|
|
// Check for legacy post-meta chat history and migrate if present.
|
|
$legacy_history = get_post_meta(
|
|
$post_id,
|
|
"_wpaw_chat_history",
|
|
true,
|
|
);
|
|
if (!empty($legacy_history) && is_array($legacy_history)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$migrated_session_id = $context_service->migrate_legacy_chat_history(
|
|
$post_id,
|
|
);
|
|
|
|
// Fetch the newly created session after migration.
|
|
$session = $manager->get_session_by_post_id($post_id);
|
|
if ($session) {
|
|
return new WP_REST_Response(
|
|
[
|
|
"messages" => $session["messages"],
|
|
"has_session" => true,
|
|
"session_id" => $session["session_id"],
|
|
"post_id" => $session["post_id"],
|
|
"migrated" => true,
|
|
"deprecated" => false,
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"messages" => [],
|
|
"has_session" => false,
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"messages" => $session["messages"],
|
|
"has_session" => true,
|
|
"session_id" => $session["session_id"],
|
|
"post_id" => $session["post_id"],
|
|
"deprecated" => false,
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update per-post chat history.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.1.4 Use conversation sessions instead. This method no longer writes
|
|
* to post meta; it exists only for backward compatibility.
|
|
* @param int $post_id Post ID.
|
|
* @param string $user_message User message.
|
|
* @param string $assistant_message Assistant message.
|
|
* @return void
|
|
*/
|
|
private function update_post_chat_history(
|
|
$post_id,
|
|
$user_message,
|
|
$assistant_message,
|
|
) {
|
|
// Deprecated - now only used for migration reads. Do not write.
|
|
// New code should use conversation sessions.
|
|
return;
|
|
}
|
|
|
|
/**
|
|
* Get per-post chat history.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.1.4 Use conversation sessions instead.
|
|
* @param int $post_id Post ID.
|
|
* @return array
|
|
*/
|
|
public function get_post_chat_history($post_id)
|
|
{
|
|
if ($post_id <= 0) {
|
|
return [];
|
|
}
|
|
|
|
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
|
$sessions = $manager->get_sessions_for_post($post_id);
|
|
|
|
// If we have active sessions, return messages from the most recent one
|
|
if (!empty($sessions)) {
|
|
// Sort by last activity, most recent first
|
|
usort($sessions, function ($a, $b) {
|
|
return strtotime($b["last_activity"] ?? "") -
|
|
strtotime($a["last_activity"] ?? "");
|
|
});
|
|
|
|
$active_session = $sessions[0];
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context = $context_service->get_context(
|
|
$active_session["session_id"],
|
|
$post_id,
|
|
);
|
|
return $context["messages"] ?? [];
|
|
}
|
|
|
|
// No sessions found - check for legacy history and migrate
|
|
$history = get_post_meta($post_id, "_wpaw_chat_history", true);
|
|
if (!is_array($history) || empty($history)) {
|
|
return [];
|
|
}
|
|
|
|
// Legacy data exists - trigger migration
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$migrated_session_id = $context_service->migrate_legacy_chat_history(
|
|
$post_id,
|
|
);
|
|
|
|
// Return migrated data using the returned session id
|
|
if (!empty($migrated_session_id)) {
|
|
$context = $context_service->get_context(
|
|
$migrated_session_id,
|
|
$post_id,
|
|
);
|
|
return $context["messages"] ?? [];
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* Get default per-post configuration values.
|
|
*
|
|
* @since 0.1.0
|
|
* @return array
|
|
*/
|
|
private function get_default_post_config()
|
|
{
|
|
$settings = get_option("wp_agentic_writer_settings", []);
|
|
|
|
return [
|
|
"article_length" => "medium",
|
|
"language" => "auto",
|
|
"tone" => "",
|
|
"audience" => "",
|
|
"experience_level" => "general",
|
|
"include_images" => true,
|
|
"web_search" =>
|
|
isset($settings["web_search_enabled"]) &&
|
|
"1" === $settings["web_search_enabled"],
|
|
"default_mode" => "writing",
|
|
// SEO fields
|
|
"focus_keyword" => "",
|
|
"seo_focus_keyword" => "",
|
|
"seo_secondary_keywords" => "",
|
|
"seo_meta_description" => "",
|
|
"seo_enabled" => true,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Sanitize post config input.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $config Post config.
|
|
* @return array
|
|
*/
|
|
public function sanitize_post_config($config)
|
|
{
|
|
$defaults = $this->get_default_post_config();
|
|
$config = is_array($config) ? $config : [];
|
|
$sanitized = [];
|
|
|
|
$allowed_lengths = ["short", "medium", "long"];
|
|
$length = $config["article_length"] ?? $defaults["article_length"];
|
|
$sanitized["article_length"] = in_array($length, $allowed_lengths, true)
|
|
? $length
|
|
: $defaults["article_length"];
|
|
|
|
// Validate language - normalize to lowercase for comparison
|
|
$settings = get_option("wp_agentic_writer_settings", []);
|
|
$allowed_languages = array_merge(
|
|
$settings["preferred_languages"] ?? [
|
|
"auto",
|
|
"English",
|
|
"Indonesian",
|
|
],
|
|
$settings["custom_languages"] ?? [],
|
|
);
|
|
// Normalize allowed languages to lowercase
|
|
$allowed_languages_lower = array_map("strtolower", $allowed_languages);
|
|
$language = strtolower($config["language"] ?? $defaults["language"]);
|
|
$sanitized["language"] = in_array(
|
|
$language,
|
|
$allowed_languages_lower,
|
|
true,
|
|
)
|
|
? $language
|
|
: "auto";
|
|
|
|
$sanitized["tone"] = sanitize_text_field(
|
|
$config["tone"] ?? $defaults["tone"],
|
|
);
|
|
$sanitized["audience"] = sanitize_text_field(
|
|
$config["audience"] ?? $defaults["audience"],
|
|
);
|
|
$sanitized["experience_level"] = sanitize_text_field(
|
|
$config["experience_level"] ?? $defaults["experience_level"],
|
|
);
|
|
|
|
$sanitized["include_images"] = isset($config["include_images"])
|
|
? (bool) $config["include_images"]
|
|
: (bool) $defaults["include_images"];
|
|
$sanitized["web_search"] = isset($config["web_search"])
|
|
? (bool) $config["web_search"]
|
|
: (bool) $defaults["web_search"];
|
|
|
|
$allowed_modes = ["writing", "planning", "chat"];
|
|
$mode = $config["default_mode"] ?? $defaults["default_mode"];
|
|
$sanitized["default_mode"] = in_array($mode, $allowed_modes, true)
|
|
? $mode
|
|
: $defaults["default_mode"];
|
|
|
|
// SEO fields
|
|
$sanitized["seo_focus_keyword"] = sanitize_text_field(
|
|
$config["seo_focus_keyword"] ?? $defaults["seo_focus_keyword"],
|
|
);
|
|
$sanitized["focus_keyword"] = sanitize_text_field(
|
|
$config["focus_keyword"] ?? $defaults["focus_keyword"],
|
|
);
|
|
if (
|
|
"" === $sanitized["focus_keyword"] &&
|
|
"" !== $sanitized["seo_focus_keyword"]
|
|
) {
|
|
$sanitized["focus_keyword"] = $sanitized["seo_focus_keyword"];
|
|
}
|
|
if (
|
|
"" === $sanitized["seo_focus_keyword"] &&
|
|
"" !== $sanitized["focus_keyword"]
|
|
) {
|
|
$sanitized["seo_focus_keyword"] = $sanitized["focus_keyword"];
|
|
}
|
|
$sanitized["seo_secondary_keywords"] = sanitize_text_field(
|
|
$config["seo_secondary_keywords"] ??
|
|
$defaults["seo_secondary_keywords"],
|
|
);
|
|
$sanitized["seo_meta_description"] = sanitize_textarea_field(
|
|
$config["seo_meta_description"] ??
|
|
$defaults["seo_meta_description"],
|
|
);
|
|
$sanitized["seo_enabled"] = isset($config["seo_enabled"])
|
|
? (bool) $config["seo_enabled"]
|
|
: (bool) $defaults["seo_enabled"];
|
|
|
|
return $sanitized;
|
|
}
|
|
|
|
/**
|
|
* Get post context bundle (all meta in single query).
|
|
*
|
|
* @since 0.3.0
|
|
* @param int $post_id Post ID.
|
|
* @return array Context bundle with memory, config, plan, etc.
|
|
*/
|
|
private function get_post_context_bundle($post_id)
|
|
{
|
|
if ($post_id <= 0) {
|
|
return [
|
|
"memory" => [],
|
|
"config" => $this->get_default_post_config(),
|
|
"plan" => [],
|
|
"plan_id" => "",
|
|
"writing_state" => [],
|
|
];
|
|
}
|
|
|
|
$all_meta = get_post_meta($post_id);
|
|
|
|
$memory = isset($all_meta["_wpaw_memory"][0])
|
|
? $all_meta["_wpaw_memory"][0]
|
|
: [];
|
|
if (!is_array($memory)) {
|
|
$memory = [];
|
|
}
|
|
|
|
$defaults = $this->get_default_post_config();
|
|
$stored_config = isset($all_meta["_wpaw_post_config"][0])
|
|
? $all_meta["_wpaw_post_config"][0]
|
|
: [];
|
|
if (!is_array($stored_config)) {
|
|
$stored_config = [];
|
|
}
|
|
$config = $this->sanitize_post_config(
|
|
wp_parse_args($stored_config, $defaults),
|
|
);
|
|
|
|
$plan = isset($all_meta["_wpaw_plan"][0])
|
|
? $all_meta["_wpaw_plan"][0]
|
|
: [];
|
|
if (!is_array($plan)) {
|
|
$plan = [];
|
|
}
|
|
|
|
$plan_id = isset($all_meta["_wpaw_plan_id"][0])
|
|
? $all_meta["_wpaw_plan_id"][0]
|
|
: "";
|
|
|
|
$writing_state = isset($all_meta["_wpaw_writing_state_updated"][0])
|
|
? $all_meta["_wpaw_writing_state_updated"][0]
|
|
: [];
|
|
if (!is_array($writing_state)) {
|
|
$writing_state = [];
|
|
}
|
|
|
|
return [
|
|
"memory" => $memory,
|
|
"config" => $config,
|
|
"plan" => $plan,
|
|
"plan_id" => $plan_id,
|
|
"writing_state" => $writing_state,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Get post config (merged with defaults).
|
|
*
|
|
* @since 0.1.0
|
|
* @param int $post_id Post ID.
|
|
* @return array
|
|
*/
|
|
public function get_post_config($post_id)
|
|
{
|
|
$bundle = $this->get_post_context_bundle($post_id);
|
|
return $bundle["config"];
|
|
}
|
|
|
|
/**
|
|
* Resolve post config from request, falling back to stored config.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $params Request params.
|
|
* @param int $post_id Post ID.
|
|
* @return array
|
|
*/
|
|
public function resolve_post_config_from_request($params, $post_id)
|
|
{
|
|
if (isset($params["postConfig"]) && is_array($params["postConfig"])) {
|
|
$merged = wp_parse_args(
|
|
$params["postConfig"],
|
|
$this->get_post_config($post_id),
|
|
);
|
|
return $this->sanitize_post_config($merged);
|
|
}
|
|
|
|
return $this->get_post_config($post_id);
|
|
}
|
|
|
|
/**
|
|
* Build a short configuration context string for prompts.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $post_config Post config.
|
|
* @return string
|
|
*/
|
|
public function build_post_config_context($post_config)
|
|
{
|
|
$lines = [];
|
|
if (!empty($post_config["tone"])) {
|
|
$lines[] = "Tone: " . $post_config["tone"];
|
|
}
|
|
if (!empty($post_config["audience"])) {
|
|
$lines[] = "Target audience: " . $post_config["audience"];
|
|
}
|
|
if (
|
|
!empty($post_config["experience_level"]) &&
|
|
"general" !== $post_config["experience_level"]
|
|
) {
|
|
$lines[] = "Expertise level: " . $post_config["experience_level"];
|
|
}
|
|
|
|
// Add SEO context if enabled
|
|
$seo_context = $this->build_seo_context($post_config);
|
|
|
|
if (empty($lines) && empty($seo_context)) {
|
|
return "";
|
|
}
|
|
|
|
$result = "";
|
|
if (!empty($lines)) {
|
|
$result .= "\nPOST CONFIG:\n- " . implode("\n- ", $lines) . "\n";
|
|
}
|
|
if (!empty($seo_context)) {
|
|
$result .= $seo_context;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* Build SEO context for prompts.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $post_config Post config.
|
|
* @return string SEO context string.
|
|
*/
|
|
private function build_seo_context($post_config)
|
|
{
|
|
if (empty($post_config["seo_enabled"])) {
|
|
return "";
|
|
}
|
|
|
|
$seo_lines = [];
|
|
|
|
if (!empty($post_config["seo_focus_keyword"])) {
|
|
$seo_lines[] =
|
|
'Focus keyword: "' .
|
|
$post_config["seo_focus_keyword"] .
|
|
'" - Include this keyword naturally in: title, first paragraph, at least 2-3 subheadings, and throughout the content (aim for 1-2% density)';
|
|
}
|
|
|
|
if (!empty($post_config["seo_secondary_keywords"])) {
|
|
$seo_lines[] =
|
|
"Secondary keywords: " .
|
|
$post_config["seo_secondary_keywords"] .
|
|
" - Sprinkle these throughout the content naturally";
|
|
}
|
|
|
|
if (empty($seo_lines)) {
|
|
return "";
|
|
}
|
|
|
|
return "\nSEO OPTIMIZATION:\n- " .
|
|
implode("\n- ", $seo_lines) .
|
|
"\n- Use descriptive, keyword-rich subheadings (H2, H3)\n- Write compelling meta-description-worthy opening paragraph\n- Include internal linking opportunities where relevant\n";
|
|
}
|
|
|
|
/**
|
|
* Detect language from text using common word patterns.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $text Text to analyze.
|
|
* @return string Detected language code.
|
|
*/
|
|
public function detect_language_from_text($text)
|
|
{
|
|
$text = strtolower($text);
|
|
|
|
// Indonesian indicators
|
|
$indonesian_words = [
|
|
"yang",
|
|
"dan",
|
|
"untuk",
|
|
"dengan",
|
|
"ini",
|
|
"itu",
|
|
"dari",
|
|
"ke",
|
|
"di",
|
|
"pada",
|
|
"adalah",
|
|
"akan",
|
|
"sudah",
|
|
"bisa",
|
|
"harus",
|
|
"tidak",
|
|
"juga",
|
|
"atau",
|
|
"saya",
|
|
"apa",
|
|
"bagaimana",
|
|
"mengapa",
|
|
"kenapa",
|
|
"gimana",
|
|
"tolong",
|
|
"mohon",
|
|
"silakan",
|
|
"terima",
|
|
"kasih",
|
|
"selamat",
|
|
"pagi",
|
|
"siang",
|
|
"malam",
|
|
"artikel",
|
|
"tentang",
|
|
"topik",
|
|
"pembahasan",
|
|
"cara",
|
|
"membuat",
|
|
"menulis",
|
|
];
|
|
$indonesian_count = 0;
|
|
foreach ($indonesian_words as $word) {
|
|
if (preg_match("/\b" . preg_quote($word, "/") . "\b/u", $text)) {
|
|
$indonesian_count++;
|
|
}
|
|
}
|
|
|
|
// Spanish indicators
|
|
$spanish_words = [
|
|
"que",
|
|
"de",
|
|
"no",
|
|
"es",
|
|
"el",
|
|
"la",
|
|
"los",
|
|
"las",
|
|
"un",
|
|
"una",
|
|
"por",
|
|
"con",
|
|
"para",
|
|
"como",
|
|
"pero",
|
|
"más",
|
|
"este",
|
|
"esta",
|
|
"todo",
|
|
"también",
|
|
"puede",
|
|
"hacer",
|
|
"tiene",
|
|
"cuando",
|
|
"sobre",
|
|
"entre",
|
|
"después",
|
|
"antes",
|
|
"porque",
|
|
"cómo",
|
|
"qué",
|
|
"cuál",
|
|
];
|
|
$spanish_count = 0;
|
|
foreach ($spanish_words as $word) {
|
|
if (preg_match("/\b" . preg_quote($word, "/") . "\b/u", $text)) {
|
|
$spanish_count++;
|
|
}
|
|
}
|
|
|
|
// French indicators
|
|
$french_words = [
|
|
"le",
|
|
"la",
|
|
"les",
|
|
"de",
|
|
"du",
|
|
"des",
|
|
"un",
|
|
"une",
|
|
"et",
|
|
"est",
|
|
"que",
|
|
"qui",
|
|
"dans",
|
|
"pour",
|
|
"pas",
|
|
"sur",
|
|
"avec",
|
|
"ce",
|
|
"cette",
|
|
"sont",
|
|
"être",
|
|
"avoir",
|
|
"faire",
|
|
"comme",
|
|
"mais",
|
|
"ou",
|
|
"où",
|
|
"plus",
|
|
"tout",
|
|
"bien",
|
|
"aussi",
|
|
"peut",
|
|
"très",
|
|
"comment",
|
|
"pourquoi",
|
|
"quoi",
|
|
];
|
|
$french_count = 0;
|
|
foreach ($french_words as $word) {
|
|
if (preg_match("/\b" . preg_quote($word, "/") . "\b/u", $text)) {
|
|
$french_count++;
|
|
}
|
|
}
|
|
|
|
// Determine language with threshold
|
|
$threshold = 2;
|
|
if (
|
|
$indonesian_count >= $threshold &&
|
|
$indonesian_count > $spanish_count &&
|
|
$indonesian_count > $french_count
|
|
) {
|
|
return "indonesian";
|
|
}
|
|
if (
|
|
$spanish_count >= $threshold &&
|
|
$spanish_count > $indonesian_count &&
|
|
$spanish_count > $french_count
|
|
) {
|
|
return "spanish";
|
|
}
|
|
if (
|
|
$french_count >= $threshold &&
|
|
$french_count > $indonesian_count &&
|
|
$french_count > $spanish_count
|
|
) {
|
|
return "french";
|
|
}
|
|
|
|
// Return empty string instead of 'auto' to allow fallback to stored language
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Resolve effective language preference.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $post_config Post config.
|
|
* @param string $fallback Language to fall back to.
|
|
* @return string
|
|
*/
|
|
public function resolve_language_preference($post_config, $fallback)
|
|
{
|
|
$language = strtolower((string) ($post_config["language"] ?? "auto"));
|
|
if ("auto" !== $language && "" !== $language) {
|
|
return $language;
|
|
}
|
|
|
|
// If fallback is provided and not empty, use it
|
|
if (!empty($fallback) && "auto" !== strtolower($fallback)) {
|
|
return strtolower($fallback);
|
|
}
|
|
|
|
// Default to 'auto' instead of 'english' to let AI detect from context
|
|
return "auto";
|
|
}
|
|
|
|
/**
|
|
* Resolve the explicit language answer from the clarity/config quiz.
|
|
*
|
|
* @since 0.2.3
|
|
* @param array $answers Clarification answers.
|
|
* @return string Language answer or empty string.
|
|
*/
|
|
public function resolve_language_from_clarification_answers($answers)
|
|
{
|
|
if (empty($answers) || !is_array($answers)) {
|
|
return "";
|
|
}
|
|
|
|
$language = "";
|
|
if (isset($answers["config_language"])) {
|
|
$language = (string) $answers["config_language"];
|
|
} else {
|
|
foreach ($answers as $answer) {
|
|
if (!is_array($answer)) {
|
|
continue;
|
|
}
|
|
if (
|
|
isset($answer["id"]) &&
|
|
"config_language" === $answer["id"]
|
|
) {
|
|
$language =
|
|
(string) ($answer["value"] ??
|
|
($answer["answer"] ?? ""));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (
|
|
"__custom__" === $language &&
|
|
!empty($answers["config_language_custom"])
|
|
) {
|
|
$language = (string) $answers["config_language_custom"];
|
|
}
|
|
|
|
$language = sanitize_text_field($language);
|
|
if (
|
|
"" === $language ||
|
|
"__skipped__" === $language ||
|
|
"auto" === strtolower($language)
|
|
) {
|
|
return "";
|
|
}
|
|
|
|
return $language;
|
|
}
|
|
|
|
/**
|
|
* Build language instruction for prompts.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $language Language code.
|
|
* @param string $context Context label.
|
|
* @return string
|
|
*/
|
|
public function build_language_instruction($language, $context = "content")
|
|
{
|
|
$language = trim((string) $language);
|
|
|
|
// If auto or empty, let AI detect from context
|
|
if (empty($language) || "auto" === strtolower($language)) {
|
|
return "CRITICAL: Detect the language from the conversation history and topic. Write ALL {$context} in the SAME language as the user's input. If the user wrote in Indonesian, write in Indonesian. If English, write in English. Match the user's language exactly.";
|
|
}
|
|
|
|
// Pass any language name directly to AI - AI models understand all languages
|
|
return "You MUST write the {$context} in {$language}. Use native {$language} vocabulary, grammar, and style.";
|
|
}
|
|
|
|
/**
|
|
* Prepend a system prompt to messages.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $messages Messages list.
|
|
* @param string $prompt System prompt.
|
|
* @return array
|
|
*/
|
|
public function prepend_system_prompt($messages, $prompt)
|
|
{
|
|
if (empty($prompt)) {
|
|
return $messages;
|
|
}
|
|
|
|
$messages = is_array($messages) ? $messages : [];
|
|
array_unshift($messages, [
|
|
"role" => "system",
|
|
"content" => $prompt,
|
|
]);
|
|
|
|
return $messages;
|
|
}
|
|
|
|
/**
|
|
* Physically scrapes the web and injects the results as a system prompt if applicable.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array &$messages Chat messages (passed by reference).
|
|
* @param object $provider AI Provider instance.
|
|
* @param array $web_search_options Web search options.
|
|
* @return void
|
|
*/
|
|
public function maybe_inject_brave_search(
|
|
&$messages,
|
|
$provider,
|
|
$web_search_options,
|
|
) {
|
|
if (empty($web_search_options["web_search_enabled"])) {
|
|
return;
|
|
}
|
|
|
|
// Check if Brave API key is configured
|
|
$settings = get_option("wp_agentic_writer_settings", []);
|
|
$brave_api_key = $settings["brave_search_api_key"] ?? "";
|
|
|
|
// Determine search strategy:
|
|
// 1. If Brave API key is set -> Use Brave (regardless of provider)
|
|
// 2. If using OpenRouter without Brave key -> Let OpenRouter's online models handle it
|
|
// 3. If using Local Backend without Brave key -> No search available
|
|
|
|
if (
|
|
empty($brave_api_key) &&
|
|
$provider instanceof WP_Agentic_Writer_OpenRouter_Provider
|
|
) {
|
|
// No Brave API key with OpenRouter - let the model's built-in search handle it
|
|
// OpenRouter's online models (e.g., gemini-2.5-flash-online) have search tools built-in
|
|
return;
|
|
}
|
|
|
|
if (empty($brave_api_key)) {
|
|
// Local Backend or other providers without Brave API key
|
|
return;
|
|
}
|
|
|
|
$last_query = "";
|
|
foreach (array_reverse($messages) as $msg) {
|
|
if ("user" === $msg["role"]) {
|
|
$last_query = (string) $msg["content"];
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (empty($last_query)) {
|
|
return;
|
|
}
|
|
|
|
$search_api = WP_Agentic_Writer_Custom_Search_API::get_instance();
|
|
$results = $search_api->search($last_query, 3);
|
|
|
|
if (!is_wp_error($results) && !empty($results)) {
|
|
$context_markdown = $search_api->format_results_for_llm(
|
|
$results,
|
|
$last_query,
|
|
);
|
|
|
|
$injection_message = [
|
|
"role" => "system",
|
|
"content" => $context_markdown,
|
|
];
|
|
|
|
$injected = false;
|
|
for ($i = count($messages) - 1; $i >= 0; $i--) {
|
|
if ("user" === $messages[$i]["role"]) {
|
|
array_splice($messages, $i, 0, [$injection_message]);
|
|
$injected = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (!$injected) {
|
|
array_unshift($messages, $injection_message);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build web search option overrides.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $post_config Post config.
|
|
* @return array
|
|
*/
|
|
public function get_web_search_options($post_config)
|
|
{
|
|
$settings = get_option("wp_agentic_writer_settings", []);
|
|
|
|
return [
|
|
"web_search_enabled" => isset($post_config["web_search"])
|
|
? (bool) $post_config["web_search"]
|
|
: false,
|
|
"search_depth" => $settings["search_depth"] ?? "medium",
|
|
"search_engine" => $settings["search_engine"] ?? "auto",
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Handle get post config request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Config instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_get_post_config($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-config.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Config($this);
|
|
return $ctrl->handle_get_post_config($request);
|
|
}
|
|
|
|
/**
|
|
* Handle update post config request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Config instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_update_post_config($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-config.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Config($this);
|
|
return $ctrl->handle_update_post_config($request);
|
|
}
|
|
|
|
/**
|
|
* Get the last user message from a message list.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $messages Message list.
|
|
* @return string
|
|
*/
|
|
public function get_last_user_message($messages)
|
|
{
|
|
if (empty($messages) || !is_array($messages)) {
|
|
return "";
|
|
}
|
|
|
|
for ($i = count($messages) - 1; $i >= 0; $i--) {
|
|
$message = $messages[$i];
|
|
if (
|
|
isset($message["role"]) &&
|
|
"user" === $message["role"] &&
|
|
!empty($message["content"])
|
|
) {
|
|
return sanitize_text_field($message["content"]);
|
|
}
|
|
}
|
|
|
|
return "";
|
|
}
|
|
|
|
/**
|
|
* Handle generate plan request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
/**
|
|
* Handle generate plan request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Planning instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_generate_plan($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-planning.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Planning($this);
|
|
return $ctrl->handle_generate_plan($request);
|
|
}
|
|
|
|
/**
|
|
* Handle revise plan request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Planning instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_revise_plan($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-planning.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Planning($this);
|
|
return $ctrl->handle_revise_plan($request);
|
|
}
|
|
|
|
/**
|
|
* Ensure plan sections have stable ids and task statuses.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $plan Plan data.
|
|
* @param array $previous_plan Previous plan for matching.
|
|
* @return array
|
|
*/
|
|
public function ensure_plan_sections_with_tasks($plan, $previous_plan = [])
|
|
{
|
|
if (empty($plan) || !is_array($plan)) {
|
|
return $plan;
|
|
}
|
|
|
|
$sections = $plan["sections"] ?? [];
|
|
if (!is_array($sections)) {
|
|
$sections = [];
|
|
}
|
|
|
|
$previous_sections = [];
|
|
if (is_array($previous_plan)) {
|
|
$previous_sections = $previous_plan["sections"] ?? [];
|
|
if (!is_array($previous_sections)) {
|
|
$previous_sections = [];
|
|
}
|
|
}
|
|
|
|
$previous_by_id = [];
|
|
$previous_by_title = [];
|
|
foreach ($previous_sections as $previous_section) {
|
|
if (!is_array($previous_section)) {
|
|
continue;
|
|
}
|
|
$previous_id = $previous_section["id"] ?? "";
|
|
$previous_title = $this->normalize_plan_section_title(
|
|
$previous_section,
|
|
);
|
|
if ($previous_id) {
|
|
$previous_by_id[$previous_id] = $previous_section;
|
|
}
|
|
if ($previous_title) {
|
|
$previous_by_title[$previous_title] = $previous_section;
|
|
}
|
|
}
|
|
|
|
$normalized_sections = [];
|
|
foreach ($sections as $section) {
|
|
if (!is_array($section)) {
|
|
continue;
|
|
}
|
|
|
|
$section_title = $this->normalize_plan_section_title($section);
|
|
$section_id = $section["id"] ?? "";
|
|
$status = $section["status"] ?? "";
|
|
|
|
if ($section_id && isset($previous_by_id[$section_id])) {
|
|
$matched = $previous_by_id[$section_id];
|
|
$status = $matched["status"] ?? $status;
|
|
} elseif (
|
|
$section_title &&
|
|
isset($previous_by_title[$section_title])
|
|
) {
|
|
$matched = $previous_by_title[$section_title];
|
|
$section_id = $matched["id"] ?? $section_id;
|
|
$status = $matched["status"] ?? $status;
|
|
}
|
|
|
|
if (empty($section_id)) {
|
|
$section_id = wp_generate_uuid4();
|
|
}
|
|
|
|
if (empty($status)) {
|
|
$status = "pending";
|
|
}
|
|
|
|
$section["id"] = $section_id;
|
|
$section["status"] = $status;
|
|
$normalized_sections[] = $section;
|
|
}
|
|
|
|
$plan["sections"] = $normalized_sections;
|
|
return $plan;
|
|
}
|
|
|
|
/**
|
|
* Normalize section title for matching.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $section Section data.
|
|
* @return string
|
|
*/
|
|
private function normalize_plan_section_title($section)
|
|
{
|
|
$title = "";
|
|
if (is_array($section)) {
|
|
$title = $section["heading"] ?? ($section["title"] ?? "");
|
|
}
|
|
$title = trim(wp_strip_all_tags((string) $title));
|
|
return strtolower($title);
|
|
}
|
|
|
|
/**
|
|
* Stream generate plan with optional auto-execution.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $topic Topic.
|
|
* @param string $context Context.
|
|
* @param int $post_id Post ID.
|
|
* @param bool $auto_execute Whether to auto-execute the article.
|
|
* @param string $article_length Article length (short, medium, or long).
|
|
* @return void Streams response to client.
|
|
*/
|
|
public function stream_generate_plan(
|
|
$topic,
|
|
$context,
|
|
$post_id,
|
|
$auto_execute,
|
|
$article_length = "medium",
|
|
$clarification_answers = [],
|
|
$detected_language = "auto",
|
|
$post_config = [],
|
|
$chat_history = [],
|
|
$session_id = "",
|
|
) {
|
|
// Set headers for streaming.
|
|
header("Content-Type: text/event-stream");
|
|
header("Cache-Control: no-cache");
|
|
header("X-Accel-Buffering: no"); // Disable Nginx buffering.
|
|
|
|
// Flush output buffer to ensure immediate streaming.
|
|
if (ob_get_level() > 0) {
|
|
ob_end_flush();
|
|
}
|
|
flush();
|
|
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"planning",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$total_cost = 0;
|
|
$post_config = $this->sanitize_post_config(
|
|
wp_parse_args($post_config, $this->get_default_post_config()),
|
|
);
|
|
$post_config_context = $this->build_post_config_context($post_config);
|
|
$web_search_options = $this->get_web_search_options($post_config);
|
|
$clarified_language = $this->resolve_language_from_clarification_answers(
|
|
$clarification_answers,
|
|
);
|
|
$effective_language = $this->resolve_language_preference(
|
|
$post_config,
|
|
$clarified_language ?: $detected_language,
|
|
);
|
|
|
|
// Extract focus keyword for context anchoring
|
|
$focus_keyword = "";
|
|
if (!empty($post_config["focus_keyword"])) {
|
|
$focus_keyword = sanitize_text_field($post_config["focus_keyword"]);
|
|
} elseif (!empty($post_config["seo_focus_keyword"])) {
|
|
$focus_keyword = sanitize_text_field(
|
|
$post_config["seo_focus_keyword"],
|
|
);
|
|
}
|
|
|
|
// Save focus keyword to post meta for persistence
|
|
if ($post_id > 0 && !empty($focus_keyword)) {
|
|
update_post_meta($post_id, "_wpaw_focus_keyword", $focus_keyword);
|
|
}
|
|
|
|
try {
|
|
// Note: Clarity check should be done BEFORE calling this streaming endpoint
|
|
// The frontend is responsible for checking clarity first via /check-clarity
|
|
// This endpoint only handles the actual streaming generation
|
|
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "provider",
|
|
"provider" => $provider_result->actual_provider,
|
|
"selectedProvider" => $provider_result->selected_provider,
|
|
"fallback_used" => $provider_result->fallback_used,
|
|
"byok_managed_by" =>
|
|
"openrouter" === $provider_result->actual_provider
|
|
? "openrouter"
|
|
: "",
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
// Send starting status
|
|
$this->send_status("starting", "Connecting to AI...");
|
|
|
|
// Step 1: Generate plan.
|
|
$this->send_status("planning", "Creating article outline...");
|
|
|
|
// Build clarification context if available.
|
|
$clarity_context = "";
|
|
if (
|
|
!empty($clarification_answers) &&
|
|
is_array($clarification_answers)
|
|
) {
|
|
$clarity_context =
|
|
"\n\n=== CONTEXT FROM CLARIFICATION QUIZ ===\n";
|
|
|
|
// Group by category.
|
|
$grouped = [];
|
|
foreach ($clarification_answers as $answer) {
|
|
$category = $answer["category"] ?? "other";
|
|
$value = $answer["value"] ?? ($answer["answer"] ?? "");
|
|
$skipped = $answer["skipped"] ?? false;
|
|
|
|
if (!$skipped && !empty($value)) {
|
|
$grouped[$category] = $value;
|
|
}
|
|
}
|
|
|
|
// Format for prompt.
|
|
$category_labels = [
|
|
"target_outcome" => "Primary Goal",
|
|
"target_audience" => "Target Audience",
|
|
"tone" => "Tone of Voice",
|
|
"content_depth" => "Content Depth",
|
|
"expertise_level" => "Expertise Level",
|
|
"content_type" => "Content Type",
|
|
"pov" => "Point of View",
|
|
];
|
|
|
|
foreach ($grouped as $category => $value) {
|
|
$label =
|
|
$category_labels[$category] ??
|
|
ucwords(str_replace("_", " ", $category));
|
|
$clarity_context .= "- {$label}: {$value}\n";
|
|
}
|
|
|
|
$clarity_context .= "=== END CONTEXT ===\n";
|
|
}
|
|
|
|
$context_builder = WP_Agentic_Writer_Context_Builder::get_instance();
|
|
$context_package = $context_builder->build_for_task(
|
|
"planning",
|
|
$session_id,
|
|
$post_id,
|
|
[
|
|
"topic" => $topic,
|
|
"context" => $context,
|
|
"chatHistory" => $chat_history,
|
|
"postConfig" => $post_config,
|
|
"clarificationAnswers" => $clarification_answers,
|
|
"detectedLanguage" => $detected_language,
|
|
],
|
|
);
|
|
$chat_history_context =
|
|
"\n\n" .
|
|
$context_package["working_context"] .
|
|
"\n\n" .
|
|
$context_package["research_context"];
|
|
|
|
// Add section limits based on article length.
|
|
$length_section_limits = [
|
|
"short" => "Create exactly 2-3 sections maximum.",
|
|
"medium" => "Create 4-5 sections maximum.",
|
|
"long" => "Create 6-8 sections maximum.",
|
|
];
|
|
$section_limit = $length_section_limits[$article_length];
|
|
|
|
// Determine language instruction for plan generation
|
|
$plan_language_instruction = $this->build_language_instruction(
|
|
$effective_language,
|
|
"article plan (title, section headings, descriptions)",
|
|
);
|
|
|
|
// Build focus keyword anchor instruction
|
|
$focus_keyword_instruction = "";
|
|
if (!empty($focus_keyword)) {
|
|
$focus_keyword_instruction = "
|
|
PRIMARY TOPIC ANCHOR: \"{$focus_keyword}\"
|
|
|
|
CRITICAL: This article MUST be about \"{$focus_keyword}\".
|
|
- The title MUST include or clearly relate to \"{$focus_keyword}\"
|
|
- All sections MUST support this primary topic
|
|
- Recent conversation refinements are meant to ENHANCE this topic, not REPLACE it
|
|
- If user discussed sub-topics, treat them as ASPECTS of the primary topic \"{$focus_keyword}\"
|
|
";
|
|
}
|
|
|
|
$system_prompt = "You are an expert content strategist and technical writer. Your task is to create a detailed article plan/outline based on the user's topic and context.
|
|
{$focus_keyword_instruction}
|
|
CRITICAL LANGUAGE REQUIREMENT:
|
|
{$plan_language_instruction}
|
|
Keep JSON keys in English for parsing, but write every user-visible JSON value (title, headings, descriptions, labels) in the required article language.
|
|
|
|
IMPORTANT CONSTRAINT: {$section_limit}
|
|
{$post_config_context}
|
|
|
|
Generate a JSON outline with the following structure:
|
|
{
|
|
\"title\": \"Article title\",
|
|
\"meta\": {
|
|
\"reading_time\": \"5 min\",
|
|
\"difficulty\": \"intermediate\",
|
|
\"cost_estimate\": 0.70
|
|
},
|
|
\"sections\": [
|
|
{
|
|
\"id\": \"unique-section-id\",
|
|
\"status\": \"pending\",
|
|
\"type\": \"section\",
|
|
\"heading\": \"Section heading\",
|
|
\"content\": [
|
|
{
|
|
\"type\": \"paragraph\",
|
|
\"content\": \"Brief description of what this section should cover\"
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
Return only valid raw JSON that matches this schema. Do not wrap it in markdown fences and do not add explanatory text.
|
|
Keep sections focused and actionable. Include H2 headings only. For technical articles, suggest code blocks.";
|
|
|
|
$memory_context = $this->get_post_memory_context($post_id);
|
|
$messages = [
|
|
[
|
|
"role" => "system",
|
|
"content" => $system_prompt,
|
|
],
|
|
[
|
|
"role" => "user",
|
|
"content" => "Topic: {$topic}\n\nContext: {$context}{$chat_history_context}{$clarity_context}{$post_config_context}{$memory_context}",
|
|
],
|
|
];
|
|
|
|
// Log the request for debugging (only when WP_DEBUG is on)
|
|
wpaw_debug_log(
|
|
"Calling OpenRouter API for planning. Topic: " .
|
|
substr($topic, 0, 100),
|
|
);
|
|
wpaw_debug_log("Detected language: " . $detected_language);
|
|
|
|
$this->maybe_inject_brave_search(
|
|
$messages,
|
|
$provider,
|
|
$web_search_options,
|
|
);
|
|
$response = $provider->chat(
|
|
$messages,
|
|
array_merge(
|
|
[
|
|
"temperature" => 0.7,
|
|
"max_tokens" => 2200,
|
|
],
|
|
$web_search_options,
|
|
),
|
|
"planning",
|
|
);
|
|
|
|
wpaw_debug_log("OpenRouter API response received");
|
|
|
|
if (is_wp_error($response)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $response->get_error_message(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
$content = $response["content"];
|
|
wpaw_debug_log(
|
|
"stream_generatePlan content length: " . strlen($content),
|
|
);
|
|
|
|
// Handle empty response gracefully
|
|
if (empty(trim((string) $content))) {
|
|
$model_used = $response["model"] ?? "unknown";
|
|
$input_tokens = $response["input_tokens"] ?? 0;
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => sprintf(
|
|
'The AI model (%s) returned an empty response. This usually means the model couldn\'t process the request. Try: 1) Use a different planning model in Settings, 2) Simplify your topic, or 3) Try again. (Tokens sent: %d)',
|
|
$model_used,
|
|
$input_tokens,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
$plan_json = $this->extract_plan_from_response($content, $topic);
|
|
|
|
if (null === $plan_json) {
|
|
wpaw_debug_log(
|
|
"extract_plan_from_response failed in streaming. Content preview: " .
|
|
substr($content, 0, 500),
|
|
);
|
|
$preview = $this->build_model_output_preview($content);
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" =>
|
|
'The AI responded but the outline couldn\'t be parsed as JSON. This sometimes happens when the model adds extra text. Trying again usually fixes this. Preview: ' .
|
|
$preview,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
$plan_json = $this->ensure_plan_sections_with_tasks($plan_json);
|
|
|
|
// MEMANTO: Remember plan was generated.
|
|
do_action("wpaw_memanto_plan_generated", $post_id, $plan_json);
|
|
|
|
// Persist planning exchange into session history.
|
|
if (!empty($session_id)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context_service->update_session_context($session_id, [
|
|
"working_summary" => [
|
|
"text" => $this->build_memory_summary_from_plan(
|
|
$plan_json,
|
|
),
|
|
"updated_at" => current_time("c"),
|
|
"source_message_count" => 0,
|
|
],
|
|
]);
|
|
$context_service->add_message($session_id, [
|
|
"role" => "user",
|
|
"content" => trim((string) $topic),
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
$context_service->add_message($session_id, [
|
|
"role" => "assistant",
|
|
"type" => "plan",
|
|
"plan" => $plan_json,
|
|
"content" => $this->build_plan_summary_for_session(
|
|
$plan_json,
|
|
$post_config,
|
|
),
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
}
|
|
|
|
// Store plan in post meta.
|
|
if ($post_id > 0) {
|
|
update_post_meta($post_id, "_wpaw_plan", $plan_json);
|
|
update_post_meta(
|
|
$post_id,
|
|
"_wpaw_detected_language",
|
|
$effective_language,
|
|
);
|
|
$summary = $this->build_memory_summary_from_plan($plan_json);
|
|
$this->update_post_memory($post_id, [
|
|
"summary" => $summary,
|
|
"last_prompt" => $topic,
|
|
"last_intent" => "generate",
|
|
]);
|
|
}
|
|
|
|
$total_cost += $response["cost"];
|
|
|
|
// Track plan cost.
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"],
|
|
"planning",
|
|
$response["input_tokens"],
|
|
$response["output_tokens"],
|
|
$response["cost"],
|
|
$provider_result,
|
|
$session_id,
|
|
"success",
|
|
);
|
|
|
|
// Send plan data.
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "plan",
|
|
"plan" => $plan_json,
|
|
"cost" => $response["cost"],
|
|
"web_search_results" =>
|
|
$response["web_search_results"] ?? [],
|
|
"context_audit" => $context_package["audit"] ?? [],
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
if (!empty($session_id)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context_service->append_session_context_item(
|
|
$session_id,
|
|
"plan_versions",
|
|
[
|
|
"instruction" => sanitize_text_field($topic),
|
|
"plan" => $plan_json,
|
|
],
|
|
10,
|
|
);
|
|
$context_service->update_session_context($session_id, [
|
|
"working_summary" => [
|
|
"text" => $this->build_memory_summary_from_plan(
|
|
$plan_json,
|
|
),
|
|
"updated_at" => current_time("c"),
|
|
"source_message_count" => 0,
|
|
],
|
|
]);
|
|
}
|
|
|
|
// Send plan complete status
|
|
if ($auto_execute) {
|
|
$this->send_status(
|
|
"plan_complete",
|
|
"Outline created! Starting to write...",
|
|
);
|
|
} else {
|
|
$this->send_status("plan_complete", "Outline ready.");
|
|
echo "data: " .
|
|
wp_json_encode(
|
|
array_merge(
|
|
[
|
|
"type" => "complete",
|
|
"totalCost" => $total_cost,
|
|
"session_id" => $session_id,
|
|
],
|
|
$this->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
),
|
|
) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
// Step 2: Auto-execute if requested.
|
|
if ($auto_execute && !empty($plan_json["sections"])) {
|
|
// Define length constraints with section counts
|
|
$length_constraints = [
|
|
"short" =>
|
|
"Write exactly 2-3 main sections. Each section should have 3-4 substantial paragraphs (4-6 sentences each). Go deep into each point with examples and explanations. Total: ~400 words.",
|
|
"medium" =>
|
|
"Write 4-5 main sections. Each section should have 2-3 meaningful paragraphs (3-5 sentences each). Balance breadth with adequate depth. Total: ~750 words.",
|
|
"long" =>
|
|
"Write 6-8 main sections. Each section should have 2-3 paragraphs (3-4 sentences each) with detailed examples and comprehensive coverage. Total: ~1500 words.",
|
|
];
|
|
|
|
$depth_instruction = [
|
|
"short" =>
|
|
"CRITICAL: Fewer sections, more depth per section. Avoid skimming. Each section should feel complete and comprehensive.",
|
|
"medium" =>
|
|
"Balance: Moderate sections with good paragraph development. Each point should be explained with at least one example.",
|
|
"long" =>
|
|
"Comprehensive: More sections covering all aspects, but still maintain substance in each paragraph.",
|
|
];
|
|
|
|
$length_instruction = $length_constraints[$article_length];
|
|
|
|
// Set post title from plan title with validation
|
|
if ($post_id > 0 && !empty($plan_json["title"])) {
|
|
// Verify post exists and user can edit
|
|
$post = get_post($post_id);
|
|
if ($post && current_user_can("edit_post", $post_id)) {
|
|
// Disable revisions during this update
|
|
add_filter(
|
|
"wp_revisions_to_keep",
|
|
"__return_zero",
|
|
999,
|
|
);
|
|
|
|
// Update post title
|
|
$update_result = wp_update_post(
|
|
[
|
|
"ID" => $post_id,
|
|
"post_title" => sanitize_text_field(
|
|
$plan_json["title"],
|
|
),
|
|
],
|
|
true, // Return WP_Error on failure
|
|
);
|
|
|
|
if (is_wp_error($update_result)) {
|
|
wpaw_debug_log(
|
|
"Failed to update post title: " .
|
|
$update_result->get_error_message(),
|
|
);
|
|
}
|
|
|
|
// Restore filters
|
|
remove_filter(
|
|
"wp_revisions_to_keep",
|
|
"__return_zero",
|
|
999,
|
|
);
|
|
|
|
// Send title update to frontend for immediate sync
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "title_update",
|
|
"title" => $plan_json["title"],
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
|
|
// Determine language instruction based on detected language
|
|
$language_instruction = $this->build_language_instruction(
|
|
$effective_language,
|
|
"ENTIRE article (conversational responses and article text)",
|
|
);
|
|
|
|
$image_instruction = "IMAGE SUGGESTIONS:
|
|
- Suggest where images would enhance understanding
|
|
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
|
|
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
|
|
- Maximum 1-2 image suggestions per section";
|
|
if (empty($post_config["include_images"])) {
|
|
$image_instruction = "IMAGE SUGGESTIONS:
|
|
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
|
|
}
|
|
|
|
$system_prompt = "You are an expert content writer and technical consultant. Your task is to provide helpful conversational feedback AND write the article content based on the provided plan.
|
|
|
|
CRITICAL LANGUAGE REQUIREMENT:
|
|
{$language_instruction}
|
|
|
|
ARTICLE LENGTH CONSTRAINT: {$length_instruction}
|
|
DEPTH GUIDELINE: {$depth_instruction[$article_length]}
|
|
{$post_config_context}
|
|
|
|
CRITICAL WRITING RULES:
|
|
1. LANGUAGE: Strictly follow the language requirement above. This is NON-NEGOTIABLE.
|
|
2. Section Count: Strictly follow the section count specified above
|
|
3. Paragraph Quality: Each paragraph must be 4-6 sentences with substance
|
|
4. No \"fluff\" - every sentence must add value
|
|
5. Examples: Include at least 1 concrete example per section
|
|
6. Avoid: Short 2-sentence paragraphs, bullet point lists without explanation
|
|
7. Code formatting: Any code/config snippets MUST be in fenced code blocks with a language tag (e.g., ```php). Never place code inline in paragraphs.
|
|
8. Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
|
|
|
|
OUTPUT FORMAT (FOLLOW THIS EXACT STRUCTURE):
|
|
|
|
First, provide a brief conversational response (2-3 sentences) in the required language about:
|
|
- What you're going to write about
|
|
- Any suggestions or notes about the content
|
|
- Reasoning for your approach
|
|
|
|
Then, insert this EXACT divider on its own line:
|
|
~~~ARTICLE~~~
|
|
|
|
After the divider, write the article in PURE Markdown format in the required language.
|
|
|
|
MARKDOWN FORMAT REQUIREMENTS:
|
|
- Use H2 (##) for section headings
|
|
- Use H3 (###) for subsections if needed
|
|
- Write clear, concise paragraphs (2-3 sentences each)
|
|
- Use bullet points or numbered lists for clarity
|
|
- Use **bold** for emphasis, *italic* for subtle emphasis
|
|
- Use `inline code` for technical terms
|
|
- Use code blocks with language specification for code examples
|
|
|
|
{$image_instruction}
|
|
|
|
EXAMPLE OUTPUT:
|
|
I'll write a comprehensive guide on this topic, focusing on practical examples and clear explanations. This approach will help readers understand both the concepts and implementation.
|
|
|
|
~~~ARTICLE~~~
|
|
## Heading Here
|
|
Content here...
|
|
|
|
Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversational response from the article content.";
|
|
|
|
$sections_to_write = [];
|
|
foreach ($plan_json["sections"] as $index => $section) {
|
|
$status = $section["status"] ?? "pending";
|
|
if ("done" === $status) {
|
|
continue;
|
|
}
|
|
$sections_to_write[$index] = $section;
|
|
}
|
|
|
|
$section_index = 0;
|
|
$total_sections = count($sections_to_write);
|
|
|
|
// Send initial writing status
|
|
$this->send_status("writing", "Writing content...");
|
|
|
|
foreach ($sections_to_write as $section_position => $section) {
|
|
$section_index++;
|
|
$is_first_section = $section_index === 1;
|
|
$heading = $section["heading"] ?? ($section["title"] ?? "");
|
|
$section_id = $section["id"] ?? wp_generate_uuid4();
|
|
$plan_json["sections"][$section_position][
|
|
"id"
|
|
] = $section_id;
|
|
$plan_json["sections"][$section_position]["status"] =
|
|
"in_progress";
|
|
if ($post_id > 0) {
|
|
update_post_meta($post_id, "_wpaw_plan", $plan_json);
|
|
}
|
|
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "section_start",
|
|
"sectionId" => $section_id,
|
|
"heading" => $heading,
|
|
"index" => $section_index,
|
|
"total" => $total_sections,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
// Send section-specific status
|
|
$this->send_status(
|
|
"writing_section",
|
|
"Writing section {$section_index} of {$total_sections}: {$heading}",
|
|
);
|
|
|
|
$section_prompt = "Write content for the \"{$heading}\" section.\n\n";
|
|
$section_prompt .= "Content requirements:\n";
|
|
|
|
if (
|
|
!empty($section["content"]) &&
|
|
is_array($section["content"])
|
|
) {
|
|
foreach ($section["content"] as $item) {
|
|
if (!empty($item["content"])) {
|
|
$section_prompt .= "- {$item["content"]}\n";
|
|
}
|
|
}
|
|
}
|
|
$section_prompt .=
|
|
"\nIMPORTANT: Start with a brief conversational note, then include ~~~ARTICLE~~~ divider, then write the section content in Markdown.\n";
|
|
if ($is_first_section) {
|
|
$section_prompt .=
|
|
"\nNOTE: This is the first section. Start directly with the section heading as an H2 (##), not an H1. The article title is already set separately.\n";
|
|
}
|
|
|
|
$messages = [
|
|
[
|
|
"role" => "system",
|
|
"content" => $system_prompt,
|
|
],
|
|
[
|
|
"role" => "user",
|
|
"content" => $section_prompt,
|
|
],
|
|
];
|
|
|
|
// Log before calling streaming API
|
|
wpaw_debug_log("Starting section generation: " . $heading);
|
|
|
|
// Send heading block first (but NOT for first section to avoid duplication with post title)
|
|
if (!$is_first_section && $heading) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "block",
|
|
"sectionId" => $section_id,
|
|
"block" => [
|
|
"type" => "heading",
|
|
"content" => $heading,
|
|
"level" => 2,
|
|
],
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
// Use streaming for real-time content generation!
|
|
$accumulated_content = "";
|
|
$section_cost = 0;
|
|
$conversational_sent = false;
|
|
$divider_found = false;
|
|
$markdown_content = ""; // Store complete markdown for later parsing
|
|
|
|
wpaw_debug_log("Calling OpenRouter streaming API");
|
|
|
|
$response = $provider->chat_stream(
|
|
$messages,
|
|
["temperature" => 0.8],
|
|
"execution",
|
|
function ($chunk, $is_complete, $full_content) use (
|
|
&$accumulated_content,
|
|
&$section_cost,
|
|
&$total_cost,
|
|
$post_id,
|
|
$provider,
|
|
&$conversational_sent,
|
|
&$divider_found,
|
|
&$markdown_content,
|
|
) {
|
|
// Accumulate the full content
|
|
$accumulated_content = $full_content;
|
|
|
|
// Check for divider
|
|
if (
|
|
!$divider_found &&
|
|
strpos(
|
|
$accumulated_content,
|
|
"~~~ARTICLE~~~",
|
|
) !== false
|
|
) {
|
|
$divider_found = true;
|
|
|
|
// Split content on divider
|
|
$parts = explode(
|
|
"~~~ARTICLE~~~",
|
|
$accumulated_content,
|
|
2,
|
|
);
|
|
$conversational = trim($parts[0]);
|
|
$markdown_content = isset($parts[1])
|
|
? trim($parts[1])
|
|
: "";
|
|
|
|
// CRITICAL: Remove any remaining divider markers from conversational content
|
|
$conversational = str_replace(
|
|
"~~~ARTICLE~~~",
|
|
"",
|
|
$conversational,
|
|
);
|
|
$conversational = preg_replace(
|
|
'/~~~ARTICLE~~~[\r\n]*/',
|
|
"",
|
|
$conversational,
|
|
);
|
|
$conversational = trim($conversational);
|
|
|
|
// Send conversational part as chat message
|
|
if (
|
|
!empty($conversational) &&
|
|
!$conversational_sent
|
|
) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "conversational",
|
|
"content" => $conversational,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
$conversational_sent = true;
|
|
}
|
|
|
|
// Stream raw markdown for display (no parsing yet)
|
|
if (!empty($markdown_content)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "markdown_stream",
|
|
"content" => $markdown_content,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
} elseif (!$divider_found) {
|
|
// No divider yet, this is all conversational
|
|
// Send conversational updates as they stream
|
|
if (!$conversational_sent) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "conversational_stream",
|
|
"content" => $accumulated_content,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
} else {
|
|
// Divider found, stream markdown content as it comes
|
|
$parts = explode(
|
|
"~~~ARTICLE~~~",
|
|
$accumulated_content,
|
|
2,
|
|
);
|
|
$markdown_content = isset($parts[1])
|
|
? trim($parts[1])
|
|
: "";
|
|
|
|
// Stream raw markdown for display (no parsing yet)
|
|
if (!empty($markdown_content)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "markdown_stream",
|
|
"content" => $markdown_content,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
if (is_wp_error($response)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $response->get_error_message(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
// Handle empty response from model
|
|
if (empty(trim((string) $accumulated_content))) {
|
|
$model_used = $response["model"] ?? "unknown";
|
|
wpaw_debug_log(
|
|
"Section writing got empty response from model: {$model_used}",
|
|
);
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => sprintf(
|
|
'Section "%s" got an empty response from the AI model (%s). Please retry.',
|
|
$heading,
|
|
$model_used,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
// If divider was never found, treat the entire content as markdown
|
|
if (!$divider_found) {
|
|
wpaw_debug_log(
|
|
"No ~~~ARTICLE~~~ divider found in section response. Using full content as markdown.",
|
|
);
|
|
$markdown_content = $accumulated_content;
|
|
// Strip any leading conversational fluff (first line if it looks like a note)
|
|
$lines = explode("\n", $markdown_content);
|
|
if (
|
|
!empty($lines[0]) &&
|
|
!preg_match("/^#{1,3}\s/", $lines[0]) &&
|
|
strlen($lines[0]) < 200
|
|
) {
|
|
// First line might be a brief conversational note, skip it
|
|
$first_line = array_shift($lines);
|
|
if (!empty($first_line)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "conversational",
|
|
"content" => trim($first_line),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
$markdown_content = implode("\n", $lines);
|
|
}
|
|
}
|
|
|
|
$section_cost = $response["cost"] ?? 0;
|
|
$total_cost += $section_cost;
|
|
|
|
// Debug: Log execution cost tracking (only when WP_DEBUG is on)
|
|
wpaw_debug_log("Tracking execution cost", [
|
|
"post_id" => $post_id,
|
|
"model" => $response["model"] ?? "unknown",
|
|
"cost" => $section_cost,
|
|
]);
|
|
|
|
// Track execution cost for this section.
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"execution",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$section_cost,
|
|
$provider_result,
|
|
$session_id ?? "",
|
|
"success",
|
|
);
|
|
|
|
// NOW parse the complete markdown content and send blocks
|
|
if (!empty($markdown_content)) {
|
|
// Extract image placeholders and generate IDs
|
|
$image_placeholders = [];
|
|
if (
|
|
preg_match_all(
|
|
"/\[IMAGE:\s*(.+?)\]/i",
|
|
$markdown_content,
|
|
$matches,
|
|
)
|
|
) {
|
|
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
|
|
|
foreach ($matches[1] as $index => $description) {
|
|
$agent_image_id =
|
|
"img_" .
|
|
$post_id .
|
|
"_" .
|
|
time() .
|
|
"_" .
|
|
($index + 1);
|
|
$image_placeholders[] = [
|
|
"agent_image_id" => $agent_image_id,
|
|
"description" => trim($description),
|
|
];
|
|
|
|
// Save to database
|
|
$image_manager->save_image_recommendation(
|
|
$post_id,
|
|
$agent_image_id,
|
|
"section_" . $section_id,
|
|
$heading,
|
|
trim($description),
|
|
trim($description),
|
|
);
|
|
}
|
|
}
|
|
|
|
$markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse(
|
|
$markdown_content,
|
|
$image_placeholders,
|
|
);
|
|
|
|
foreach ($markdown_blocks as $block) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "block",
|
|
"block" => $block,
|
|
"sectionId" => $section_id,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
|
|
$plan_json["sections"][$section_position]["status"] =
|
|
"done";
|
|
if ($post_id > 0) {
|
|
update_post_meta($post_id, "_wpaw_plan", $plan_json);
|
|
}
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "section_complete",
|
|
"sectionId" => $section_id,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
|
|
// Send complete status
|
|
$this->send_status("complete", "Article finished!");
|
|
|
|
// Send conversational completion message before complete signal
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "conversational",
|
|
"content" =>
|
|
"✅ Article generation complete! The content has been added to your editor. Feel free to ask for refinements or adjustments to any section.",
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
// Send completion message.
|
|
echo "data: " .
|
|
wp_json_encode(
|
|
array_merge(
|
|
[
|
|
"type" => "complete",
|
|
"totalCost" => $total_cost,
|
|
"session_id" => $session_id,
|
|
],
|
|
$this->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
),
|
|
) .
|
|
"\n\n";
|
|
flush();
|
|
} catch (Exception $e) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $e->getMessage(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Build a compact, persistent outline summary for session history.
|
|
*
|
|
* @since 0.2.2
|
|
* @param array $plan_json Plan data.
|
|
* @param array $post_config Post config.
|
|
* @return string
|
|
*/
|
|
public function build_plan_summary_for_session(
|
|
$plan_json,
|
|
$post_config = [],
|
|
) {
|
|
$title = trim((string) ($plan_json["title"] ?? "Outline ready"));
|
|
$sections = is_array($plan_json["sections"] ?? null)
|
|
? $plan_json["sections"]
|
|
: [];
|
|
|
|
$lines = [];
|
|
$lines[] = "Outline ready.";
|
|
$lines[] = "";
|
|
$lines[] = $title;
|
|
$lines[] = "";
|
|
|
|
$focus = trim((string) ($post_config["seo_focus_keyword"] ?? ""));
|
|
$secondary = trim(
|
|
(string) ($post_config["seo_secondary_keywords"] ?? ""),
|
|
);
|
|
if ("" !== $focus || "" !== $secondary) {
|
|
$lines[] = "SEO Snapshot:";
|
|
if ("" !== $focus) {
|
|
$lines[] = "- Focus: " . $focus;
|
|
}
|
|
if ("" !== $secondary) {
|
|
$lines[] = "- Secondary: " . $secondary;
|
|
}
|
|
$lines[] = "";
|
|
}
|
|
|
|
$lines[] = "Sections:";
|
|
$index = 1;
|
|
foreach ($sections as $section) {
|
|
$heading = trim(
|
|
(string) ($section["heading"] ?? ($section["title"] ?? "")),
|
|
);
|
|
if ("" === $heading) {
|
|
continue;
|
|
}
|
|
$lines[] = $index . ". " . $heading;
|
|
$index++;
|
|
}
|
|
|
|
return implode("\n", $lines);
|
|
}
|
|
|
|
/**
|
|
* Handle execute article request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
/**
|
|
* Handle execute article request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Writing::handle_execute_article() instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_execute_article($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-writing.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Writing($this);
|
|
return $controller->handle_execute_article($request);
|
|
}
|
|
|
|
/**
|
|
* Stream article execution from a stored plan.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $plan Article plan.
|
|
* @param int $post_id Post ID.
|
|
* @param array $post_config Post configuration.
|
|
* @param string $effective_language Effective language.
|
|
* @param string $session_id Session ID.
|
|
* @return void Streams response to client.
|
|
*/
|
|
public function stream_execute_article(
|
|
$plan,
|
|
$post_id,
|
|
$post_config,
|
|
$effective_language,
|
|
$session_id,
|
|
) {
|
|
// Set headers for streaming.
|
|
header("Content-Type: text/event-stream");
|
|
header("Cache-Control: no-cache");
|
|
header("X-Accel-Buffering: no");
|
|
|
|
// Flush output buffer.
|
|
if (ob_get_level() > 0) {
|
|
ob_end_clean();
|
|
}
|
|
flush();
|
|
|
|
// Get provider for writing task.
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"writing",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
|
|
$post_config = $this->sanitize_post_config(
|
|
wp_parse_args($post_config, $this->get_default_post_config()),
|
|
);
|
|
$post_config_context = $this->build_post_config_context($post_config);
|
|
$language_instruction = $this->build_language_instruction(
|
|
$effective_language,
|
|
"article content",
|
|
);
|
|
|
|
// Build image instruction.
|
|
$image_instruction = "IMAGE SUGGESTIONS:
|
|
- Suggest where images would enhance understanding
|
|
- Place image suggestions on their own line: [IMAGE: descriptive alt text]
|
|
- Maximum 1 image per section";
|
|
if (empty($post_config["include_images"])) {
|
|
$image_instruction = "IMAGE SUGGESTIONS:
|
|
- Do NOT include any image suggestions.";
|
|
}
|
|
|
|
// Build SEO instructions if enabled.
|
|
$seo_instruction = "";
|
|
if (
|
|
!empty($post_config["seo_enabled"]) &&
|
|
!empty($post_config["seo_focus_keyword"])
|
|
) {
|
|
$focus_keyword = $post_config["seo_focus_keyword"];
|
|
$seo_instruction = "\n\nSEO REQUIREMENTS:
|
|
- Focus Keyword: \"{$focus_keyword}\"
|
|
- Include focus keyword in title, first paragraph, and 2-3 headings.";
|
|
}
|
|
|
|
// Build system prompt.
|
|
$system_prompt = "You are an industry practitioner sharing insights with a colleague.
|
|
|
|
ANTI-ROBOT RULES:
|
|
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament.
|
|
- BURSTINESS: Mix short, punchy sentences with longer ones.
|
|
- TONE: Conversational, direct, pragmatic.
|
|
|
|
CRITICAL LANGUAGE REQUIREMENT:
|
|
{$language_instruction}
|
|
{$post_config_context}
|
|
|
|
Write engaging, high-information-density content.
|
|
{$seo_instruction}
|
|
{$image_instruction}";
|
|
|
|
$plan = $this->ensure_plan_sections_with_tasks($plan);
|
|
|
|
// Update post title if available.
|
|
if (!empty($plan["title"]) && $post_id > 0) {
|
|
$post = get_post($post_id);
|
|
if (
|
|
$post &&
|
|
empty($post->post_title) &&
|
|
current_user_can("edit_post", $post_id)
|
|
) {
|
|
wp_update_post([
|
|
"ID" => $post_id,
|
|
"post_title" => sanitize_text_field($plan["title"]),
|
|
]);
|
|
}
|
|
}
|
|
|
|
// MEMANTO: Plan execution implies approval.
|
|
do_action("wpaw_memanto_plan_approved", $post_id, $plan);
|
|
|
|
// Build sections to write.
|
|
$sections_to_write = [];
|
|
foreach ($plan["sections"] as $index => $section) {
|
|
if (($section["status"] ?? "pending") !== "done") {
|
|
$sections_to_write[$index] = $section;
|
|
}
|
|
}
|
|
|
|
$total_cost = 0;
|
|
$all_blocks = [];
|
|
$section_count = count($sections_to_write);
|
|
$current_section = 0;
|
|
|
|
foreach ($sections_to_write as $section) {
|
|
$current_section++;
|
|
$heading = $section["heading"] ?? ($section["title"] ?? "");
|
|
|
|
$this->send_status(
|
|
"writing_section",
|
|
"Writing: " . ($heading ?: "Section " . $current_section),
|
|
);
|
|
|
|
if ($heading) {
|
|
$all_blocks[] = [
|
|
"type" => "heading",
|
|
"content" => $heading,
|
|
"level" => 2,
|
|
];
|
|
}
|
|
|
|
$section_prompt = $heading
|
|
? "Write the \"{$heading}\" section.\n\n"
|
|
: "Write the next section.\n\n";
|
|
$section_prompt .= "Content requirements:\n";
|
|
|
|
if (!empty($section["content"]) && is_array($section["content"])) {
|
|
foreach ($section["content"] as $item) {
|
|
if (!empty($item["content"])) {
|
|
$section_prompt .= "- {$item["content"]}\n";
|
|
}
|
|
}
|
|
}
|
|
|
|
$messages = [
|
|
["role" => "system", "content" => $system_prompt],
|
|
["role" => "user", "content" => $section_prompt],
|
|
];
|
|
|
|
$response = $provider->chat(
|
|
$messages,
|
|
["temperature" => 0.8],
|
|
"execution",
|
|
);
|
|
|
|
if (is_wp_error($response)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $response->get_error_message(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
$section_blocks = WP_Agentic_Writer_Markdown_Parser::parse(
|
|
$response["content"],
|
|
);
|
|
if (!empty($section_blocks)) {
|
|
$first_block = $section_blocks[0];
|
|
if (
|
|
isset($first_block["blockName"]) &&
|
|
"core/heading" === $first_block["blockName"]
|
|
) {
|
|
$first_heading = $first_block["attrs"]["content"] ?? "";
|
|
if (
|
|
$heading &&
|
|
$first_heading &&
|
|
0 === strcasecmp(trim($first_heading), trim($heading))
|
|
) {
|
|
array_shift($section_blocks);
|
|
}
|
|
}
|
|
foreach ($section_blocks as $block) {
|
|
$all_blocks[] = $block;
|
|
}
|
|
} else {
|
|
$all_blocks[] = [
|
|
"type" => "paragraph",
|
|
"content" => $response["content"],
|
|
];
|
|
}
|
|
|
|
$total_cost += $response["cost"];
|
|
|
|
// Send progress update.
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "progress",
|
|
"current" => $current_section,
|
|
"total" => $section_count,
|
|
"heading" => $heading,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
// MEMANTO: Remember section written.
|
|
$section_id = $section["id"] ?? sanitize_title($heading);
|
|
do_action(
|
|
"wpaw_memanto_section_written",
|
|
$post_id,
|
|
$section_id,
|
|
$heading,
|
|
);
|
|
}
|
|
|
|
// Mark sections as done.
|
|
if (!empty($sections_to_write)) {
|
|
foreach (array_keys($sections_to_write) as $section_index) {
|
|
$plan["sections"][$section_index]["status"] = "done";
|
|
}
|
|
if ($post_id > 0) {
|
|
update_post_meta($post_id, "_wpaw_plan", $plan);
|
|
}
|
|
}
|
|
|
|
// Track cost.
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$this->get_provider_execution_model($provider, "execution"),
|
|
"execution",
|
|
0,
|
|
0,
|
|
$total_cost,
|
|
$provider_result,
|
|
"",
|
|
"success",
|
|
);
|
|
|
|
// Send completion.
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "complete",
|
|
"blocks" => $all_blocks,
|
|
"cost" => $total_cost,
|
|
"recommended_title" => $plan["title"] ?? "",
|
|
"provider_metadata" => $this->build_provider_metadata(
|
|
$provider_result,
|
|
$this->get_provider_execution_model($provider, "execution"),
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Handle reformat blocks request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
/**
|
|
* Handle reformat blocks request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Writing::handle_reformat_blocks() instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_reformat_blocks($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-writing.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Writing($this);
|
|
return $controller->handle_reformat_blocks($request);
|
|
}
|
|
|
|
/**
|
|
* Handle regenerate block request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
/**
|
|
* Handle regenerate block request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Refinement::handle_regenerate_block() instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_regenerate_block($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-refinement.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Refinement($this);
|
|
return $controller->handle_regenerate_block($request);
|
|
}
|
|
|
|
/**
|
|
* Handle get cost tracking request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Cost instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_get_cost_tracking($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-cost.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Cost($this);
|
|
return $controller->handle_get_cost_tracking($request);
|
|
}
|
|
|
|
/**
|
|
* Extract JSON from string.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $string String containing JSON.
|
|
* @return array|null Decoded JSON or null if invalid.
|
|
*/
|
|
/**
|
|
* Extract JSON from model response text.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_json() instead.
|
|
* @param string $string Raw model response.
|
|
* @return array|null
|
|
*/
|
|
public function extract_json($string)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::extract_json($string);
|
|
}
|
|
|
|
/**
|
|
* Extract balanced JSON object candidates from model text.
|
|
*
|
|
* @since 0.2.2
|
|
* @param string $string Source text.
|
|
* @return array
|
|
*/
|
|
/**
|
|
* Extract balanced JSON object candidates.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_balanced_json_candidates() instead.
|
|
* @param string $string Source text.
|
|
* @param string $open_char Opening character.
|
|
* @param string $close_char Closing character.
|
|
* @return array
|
|
*/
|
|
private function extract_balanced_json_candidates(
|
|
$string,
|
|
$open_char = "{",
|
|
$close_char = "}",
|
|
) {
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::extract_balanced_json_candidates(
|
|
$string,
|
|
$open_char,
|
|
$close_char,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract an article plan from model output, falling back to markdown outlines.
|
|
*
|
|
* @since 0.2.2
|
|
* @param string $content Model response.
|
|
* @param string $fallback_title Fallback title/topic.
|
|
* @param array $previous_plan Previous plan for revisions.
|
|
* @return array|null
|
|
*/
|
|
/**
|
|
* Extract plan from model response.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response() instead.
|
|
* @param string $content Raw model response.
|
|
* @param string $fallback_title Fallback title.
|
|
* @param array $previous_plan Previous plan.
|
|
* @return array|null
|
|
*/
|
|
private function extract_plan_from_response(
|
|
$content,
|
|
$fallback_title = "",
|
|
$previous_plan = [],
|
|
) {
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::extract_plan_from_response(
|
|
$content,
|
|
$fallback_title,
|
|
$previous_plan,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a short, safe preview of unparseable model output.
|
|
*
|
|
* @since 0.2.3
|
|
* @param string $content Model response.
|
|
* @return string
|
|
*/
|
|
public function build_model_output_preview($content)
|
|
{
|
|
$preview = trim(wp_strip_all_tags((string) $content));
|
|
$preview = preg_replace("/\s+/", " ", $preview);
|
|
if (function_exists("mb_substr")) {
|
|
$preview = mb_substr($preview, 0, 240);
|
|
} else {
|
|
$preview = substr($preview, 0, 240);
|
|
}
|
|
|
|
return "" !== $preview ? $preview : "(empty response)";
|
|
}
|
|
|
|
/**
|
|
* Normalize common model outline JSON variants into the required plan schema.
|
|
*
|
|
* @since 0.2.3
|
|
* @param mixed $json Decoded model JSON.
|
|
* @param string $fallback_title Fallback title/topic.
|
|
* @return array|null
|
|
*/
|
|
/**
|
|
* Normalize extracted plan JSON.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::normalize_extracted_plan_json() instead.
|
|
* @param mixed $json Decoded JSON.
|
|
* @param string $fallback_title Fallback title.
|
|
* @return array|null
|
|
*/
|
|
private function normalize_extracted_plan_json($json, $fallback_title = "")
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::normalize_extracted_plan_json(
|
|
$json,
|
|
$fallback_title,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Normalize varied model section content into plan content items.
|
|
*
|
|
* @since 0.2.3
|
|
* @param mixed $items Section content candidate.
|
|
* @return array
|
|
*/
|
|
/**
|
|
* Normalize plan section content items.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::normalize_plan_section_content_items() instead.
|
|
* @param mixed $items Section content.
|
|
* @return array
|
|
*/
|
|
private function normalize_plan_section_content_items($items)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::normalize_plan_section_content_items(
|
|
$items,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a plan schema from markdown/numbered outline output.
|
|
*
|
|
* @since 0.2.2
|
|
* @param string $content Model response.
|
|
* @param string $fallback_title Fallback title/topic.
|
|
* @param array $previous_plan Previous plan for revisions.
|
|
* @return array|null
|
|
*/
|
|
/**
|
|
* Build plan from markdown outline.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::build_plan_from_markdown_outline() instead.
|
|
* @param string $content Markdown content.
|
|
* @param string $fallback_title Fallback title.
|
|
* @param array $previous_plan Previous plan.
|
|
* @return array|null
|
|
*/
|
|
private function build_plan_from_markdown_outline(
|
|
$content,
|
|
$fallback_title = "",
|
|
$previous_plan = [],
|
|
) {
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::build_plan_from_markdown_outline(
|
|
$content,
|
|
$fallback_title,
|
|
$previous_plan,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clean markdown decoration from an outline heading.
|
|
*
|
|
* @since 0.2.2
|
|
* @param string $heading Heading text.
|
|
* @return string
|
|
*/
|
|
/**
|
|
* Clean outline heading.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::clean_outline_heading() instead.
|
|
* @param string $heading Heading text.
|
|
* @return string
|
|
*/
|
|
private function clean_outline_heading($heading)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::clean_outline_heading(
|
|
$heading,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle get models request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Models instead.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_get_models()
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-models.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Models($this);
|
|
return $controller->handle_get_models();
|
|
}
|
|
|
|
/**
|
|
* Handle refresh models request.
|
|
*
|
|
* @since 0.1.0
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Models instead.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_refresh_models()
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-models.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Models($this);
|
|
return $controller->handle_refresh_models();
|
|
}
|
|
|
|
/**
|
|
* Handle check clarity request.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_check_clarity($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Clarity($this);
|
|
return $controller->handle_check_clarity($request);
|
|
}
|
|
|
|
/**
|
|
* Append configuration questions to clarity quiz.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead.
|
|
* @param array $questions Existing questions.
|
|
* @param array $post_config Post configuration.
|
|
* @return array Updated questions with config prompts.
|
|
*/
|
|
private function append_config_questions($questions, $post_config)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Clarity($this);
|
|
return $controller->append_config_questions($questions, $post_config);
|
|
}
|
|
|
|
/**
|
|
* Handle block refine request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_block_refine($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$block_id = $params["blockId"] ?? "";
|
|
$block_type = $params["blockType"] ?? "";
|
|
$block_content = $params["blockContent"] ?? "";
|
|
$refinement_request = $params["refinementRequest"] ?? "";
|
|
$article_context = $params["articleContext"] ?? [];
|
|
$post_id = $params["postId"] ?? 0;
|
|
$stream = $params["stream"] ?? false;
|
|
$chat_history = $params["chatHistory"] ?? [];
|
|
|
|
if (empty($block_content) || empty($refinement_request)) {
|
|
return new WP_Error(
|
|
"missing_data",
|
|
__(
|
|
"Block content and refinement request are required.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
// Check post permission BEFORE reading post data.
|
|
if ($post_id > 0 && !$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
// Only read post config after permission check.
|
|
$post_config = $this->resolve_post_config_from_request(
|
|
$params,
|
|
$post_id,
|
|
);
|
|
|
|
// If streaming is requested, use streaming response.
|
|
if ($stream) {
|
|
return $this->stream_block_refine(
|
|
$block_id,
|
|
$block_type,
|
|
$block_content,
|
|
$refinement_request,
|
|
$article_context,
|
|
$post_id,
|
|
$post_config,
|
|
);
|
|
}
|
|
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"refinement",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
|
|
// Build context from article structure.
|
|
$context_str = "\n\nArticle Context:\n";
|
|
$context_str .=
|
|
"Title: " . ($article_context["title"] ?? "Unknown") . "\n";
|
|
|
|
if (!empty($article_context["previousBlock"])) {
|
|
$context_str .=
|
|
"Previous section: " .
|
|
$article_context["previousBlock"]["heading"] .
|
|
"\n";
|
|
}
|
|
|
|
$context_str .= "Current block type: " . $block_type . "\n";
|
|
$context_str .= "Current content:\n" . $block_content . "\n";
|
|
|
|
if (!empty($article_context["nextBlock"])) {
|
|
$context_str .=
|
|
"Next section: " .
|
|
$article_context["nextBlock"]["heading"] .
|
|
"\n";
|
|
}
|
|
|
|
// Add chat history context if available
|
|
$chat_history_context = "";
|
|
if (!empty($chat_history) && is_array($chat_history)) {
|
|
$chat_history_context = "\n\n--- ORIGINAL CONVERSATION ---\n";
|
|
foreach ($chat_history as $msg) {
|
|
$role = isset($msg["role"]) ? ucfirst($msg["role"]) : "Unknown";
|
|
$content = isset($msg["content"]) ? $msg["content"] : "";
|
|
if (
|
|
!empty($content) &&
|
|
"system" !== strtolower($msg["role"] ?? "")
|
|
) {
|
|
$chat_history_context .= "{$role}: {$content}\n\n";
|
|
}
|
|
}
|
|
$chat_history_context .= "--- END CONVERSATION ---\n";
|
|
$chat_history_context .=
|
|
"This shows the original discussion that led to this article.";
|
|
}
|
|
|
|
// Add plan context if available
|
|
$plan_context = "";
|
|
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
|
if (!empty($plan) && is_array($plan)) {
|
|
$plan_context = "\n\nOriginal Article Outline:\n";
|
|
if (!empty($plan["title"])) {
|
|
$plan_context .= "Title: {$plan["title"]}\n";
|
|
}
|
|
if (!empty($plan["sections"]) && is_array($plan["sections"])) {
|
|
foreach ($plan["sections"] as $section) {
|
|
$heading = $section["heading"] ?? ($section["title"] ?? "");
|
|
if (!empty($heading)) {
|
|
$plan_context .= "- {$heading}\n";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
$system_prompt = "You are an expert editor helping refine a specific section of an article.
|
|
|
|
{$context_str}
|
|
{$plan_context}
|
|
{$chat_history_context}
|
|
|
|
USER REQUEST: {$refinement_request}
|
|
|
|
TASK:
|
|
Refine the current section content considering:
|
|
1. How it fits into the overall article flow
|
|
2. Consistency with surrounding sections
|
|
3. The original intent from the conversation and outline
|
|
4. The user's specific refinement request
|
|
5. Maintaining the original key information
|
|
|
|
Provide the refined content in Markdown format.
|
|
Keep the same block type (paragraph, heading, list, etc.).";
|
|
|
|
$messages = [
|
|
[
|
|
"role" => "system",
|
|
"content" => $system_prompt,
|
|
],
|
|
[
|
|
"role" => "user",
|
|
"content" => "Please refine this content.",
|
|
],
|
|
];
|
|
|
|
$response = $provider->chat(
|
|
$messages,
|
|
["temperature" => 0.7],
|
|
"execution",
|
|
);
|
|
|
|
if (is_wp_error($response)) {
|
|
return new WP_Error(
|
|
"refinement_error",
|
|
$response->get_error_message(),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
// Parse refined content as Gutenberg blocks.
|
|
$blocks = WP_Agentic_Writer_Markdown_Parser::parse(
|
|
$response["content"],
|
|
);
|
|
|
|
// MEMANTO: Remember block refinement.
|
|
do_action(
|
|
"wpaw_memanto_block_refined",
|
|
$post_id,
|
|
$block_id,
|
|
$refinement_request,
|
|
);
|
|
|
|
// Track cost (always track for debugging).
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"block_refinement",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$response["cost"] ?? 0,
|
|
$provider_result,
|
|
$session_id,
|
|
"success",
|
|
);
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"blocks" => $blocks,
|
|
"blockId" => $block_id,
|
|
"cost" => $response["cost"] ?? 0,
|
|
"provider_metadata" => $this->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stream block refinement response.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $block_id Block ID.
|
|
* @param string $block_type Block type.
|
|
* @param string $block_content Block content.
|
|
* @param string $refinement_request Refinement request.
|
|
* @param array $article_context Article context.
|
|
* @param int $post_id Post ID.
|
|
* @return void Streams response to client.
|
|
*/
|
|
public function stream_block_refine(
|
|
$block_id,
|
|
$block_type,
|
|
$block_content,
|
|
$refinement_request,
|
|
$article_context,
|
|
$post_id,
|
|
$post_config = [],
|
|
) {
|
|
// Set headers for streaming.
|
|
header("Content-Type: text/event-stream");
|
|
header("Cache-Control: no-cache");
|
|
header("X-Accel-Buffering: no"); // Disable Nginx buffering.
|
|
|
|
// Flush output buffer to ensure immediate streaming.
|
|
if (ob_get_level() > 0) {
|
|
ob_end_flush();
|
|
}
|
|
flush();
|
|
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"refinement",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$post_config = $this->sanitize_post_config(
|
|
wp_parse_args($post_config, $this->get_default_post_config()),
|
|
);
|
|
$post_config_context = $this->build_post_config_context($post_config);
|
|
$stored_language = get_post_meta(
|
|
$post_id,
|
|
"_wpaw_detected_language",
|
|
true,
|
|
);
|
|
$effective_language = $this->resolve_language_preference(
|
|
$post_config,
|
|
$stored_language,
|
|
);
|
|
$language_instruction = $this->build_language_instruction(
|
|
$effective_language,
|
|
"refined content",
|
|
);
|
|
|
|
try {
|
|
// Build context from article structure.
|
|
$context_str = "\n\nArticle Context:\n";
|
|
$context_str .=
|
|
"Title: " . ($article_context["title"] ?? "Unknown") . "\n";
|
|
|
|
if (!empty($article_context["previousBlock"])) {
|
|
$context_str .=
|
|
"Previous section: " .
|
|
$article_context["previousBlock"]["heading"] .
|
|
"\n";
|
|
}
|
|
|
|
$context_str .= "Current block type: " . $block_type . "\n";
|
|
$context_str .= "Current content:\n" . $block_content . "\n";
|
|
|
|
if (!empty($article_context["nextBlock"])) {
|
|
$context_str .=
|
|
"Next section: " .
|
|
$article_context["nextBlock"]["heading"] .
|
|
"\n";
|
|
}
|
|
|
|
$system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request.
|
|
|
|
ANTI-ROBOT RULES:
|
|
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament.
|
|
- Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested.
|
|
- Increase specificity and information density. Do not just increase word count with conversational filler.
|
|
|
|
CRITICAL LANGUAGE REQUIREMENT:
|
|
{$language_instruction}
|
|
|
|
{$post_config_context}
|
|
|
|
{$context_str}
|
|
|
|
USER REQUEST: {$refinement_request}
|
|
|
|
IMPORTANT RULES:
|
|
1. Rewrite the content to fulfill the refinement request
|
|
2. Maintain the core meaning and key information
|
|
3. Ensure it flows well with surrounding sections
|
|
4. Match the article's overall tone and style
|
|
5. Return ONLY the refined content, no explanations or conversational text
|
|
|
|
Output format:
|
|
- If paragraph: Return the refined text only
|
|
- If heading: Return the refined heading text only
|
|
- If list: Return the list items, one per line
|
|
- No markdown formatting like ```text`` wrappers
|
|
- No conversational filler
|
|
- Start directly with the refined content";
|
|
|
|
$messages = [
|
|
[
|
|
"role" => "system",
|
|
"content" => $system_prompt,
|
|
],
|
|
[
|
|
"role" => "user",
|
|
"content" => "Refine this content.",
|
|
],
|
|
];
|
|
|
|
$response = $provider->chat(
|
|
$messages,
|
|
["temperature" => 0.7],
|
|
"execution",
|
|
);
|
|
|
|
if (is_wp_error($response)) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $response->get_error_message(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
|
|
// Track cost (always track for debugging).
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"block_refinement",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$response["cost"] ?? 0,
|
|
$provider_result,
|
|
$session_id ?? "",
|
|
"success",
|
|
);
|
|
|
|
$payload = $this->parse_refined_payload($response["content"]);
|
|
$refined_content = $this->clean_refined_content(
|
|
$payload["content"],
|
|
);
|
|
$resolved_block_type = $payload["blockType"] ?? $block_type;
|
|
|
|
// Parse as block based on type and create proper Gutenberg block structure
|
|
$block_data = [];
|
|
$block_name = "core/paragraph"; // Default
|
|
|
|
if ($resolved_block_type === "core/paragraph") {
|
|
$block_name = "core/paragraph";
|
|
$block_attrs = ["content" => $refined_content];
|
|
// Create proper HTML for paragraph
|
|
$block_html = "<p>" . $refined_content . "</p>";
|
|
// Create proper block structure
|
|
$block_data = [
|
|
"blockName" => $block_name,
|
|
"attrs" => $block_attrs,
|
|
"innerHTML" => $block_html,
|
|
"clientId" => $block_id,
|
|
];
|
|
} elseif ($resolved_block_type === "core/heading") {
|
|
$block_name = "core/heading";
|
|
// Detect heading level from markdown-style if present
|
|
$level = 2;
|
|
if (preg_match("/^(#{1,6})\s/", $refined_content)) {
|
|
$count = strspn($refined_content, "#");
|
|
$level = min($count, 6);
|
|
$refined_content = trim(substr($refined_content, $count));
|
|
}
|
|
$block_attrs = [
|
|
"level" => $level,
|
|
"content" => $refined_content,
|
|
];
|
|
$tag = "h" . $level;
|
|
$block_html = "<{$tag}>{$refined_content}</{$tag}>";
|
|
$block_data = [
|
|
"blockName" => $block_name,
|
|
"attrs" => $block_attrs,
|
|
"innerHTML" => $block_html,
|
|
"clientId" => $block_id,
|
|
];
|
|
} elseif ($resolved_block_type === "core/list") {
|
|
$block_name = "core/list";
|
|
$lines = explode("\n", $refined_content);
|
|
$lines = array_filter(array_map("trim", $lines));
|
|
|
|
// Create inner blocks for list items
|
|
$inner_blocks = [];
|
|
foreach ($lines as $line) {
|
|
$inner_blocks[] = [
|
|
"blockName" => "core/list-item",
|
|
"attrs" => ["content" => $line],
|
|
"innerHTML" => "<li>" . $line . "</li>",
|
|
];
|
|
}
|
|
|
|
$block_attrs = ["ordered" => false];
|
|
$block_html =
|
|
"<ul>" .
|
|
implode(
|
|
"",
|
|
array_map(function ($item) {
|
|
return $item["innerHTML"];
|
|
}, $inner_blocks),
|
|
) .
|
|
"</ul>";
|
|
|
|
$block_data = [
|
|
"blockName" => $block_name,
|
|
"attrs" => $block_attrs,
|
|
"innerBlocks" => $inner_blocks,
|
|
"innerHTML" => $block_html,
|
|
"clientId" => $block_id,
|
|
];
|
|
} else {
|
|
// Fallback to paragraph for unknown types
|
|
$block_name = "core/paragraph";
|
|
$block_attrs = ["content" => $refined_content];
|
|
$block_html = "<p>" . $refined_content . "</p>";
|
|
$block_data = [
|
|
"blockName" => $block_name,
|
|
"attrs" => $block_attrs,
|
|
"innerHTML" => $block_html,
|
|
"clientId" => $block_id,
|
|
];
|
|
}
|
|
|
|
// Send the refined block
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "block",
|
|
"block" => $block_data,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
// Small delay for visual effect
|
|
usleep(100000);
|
|
|
|
// Send completion message with provider metadata.
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "complete",
|
|
"blockId" => $block_id,
|
|
"totalCost" => $response["cost"],
|
|
"provider_metadata" => $this->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
// MEMANTO: Remember block was refined.
|
|
do_action(
|
|
"wpaw_memanto_block_refined",
|
|
$post_id,
|
|
$block_id,
|
|
$refinement_request,
|
|
);
|
|
} catch (Exception $e) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $e->getMessage(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Check clarity before article generation.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead.
|
|
* @param string $topic User topic.
|
|
* @param array $answers Previous answers.
|
|
* @param mixed $provider OpenRouter provider.
|
|
* @return array Clarity check result with is_clear and questions.
|
|
*/
|
|
private function check_clarity_before_generation(
|
|
$topic,
|
|
$answers,
|
|
$provider,
|
|
) {
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Clarity($this);
|
|
return $controller->check_clarity_before_generation(
|
|
$topic,
|
|
$answers,
|
|
$provider,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Send status update via SSE.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $status Status code.
|
|
* @param string $message Status message.
|
|
*/
|
|
private function send_status($status, $message = "")
|
|
{
|
|
$status_icons = [
|
|
"starting" => "",
|
|
"planning" => "",
|
|
"plan_complete" => "",
|
|
"writing" => "",
|
|
"writing_section" => "",
|
|
"complete" => "",
|
|
];
|
|
|
|
$icon = isset($status_icons[$status]) ? $status_icons[$status] : "";
|
|
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"status" => $status,
|
|
"message" => $message,
|
|
"icon" => $icon,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
/**
|
|
* Get default clarification questions when AI fails.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Clarity instead.
|
|
* @param string $topic User's topic.
|
|
* @return array Clarification result with default questions.
|
|
*/
|
|
private function get_default_clarification_questions($topic)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-clarity.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Clarity($this);
|
|
return $controller->get_default_clarification_questions($topic);
|
|
}
|
|
|
|
/**
|
|
* Handle chat-based block refinement request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request Full request data.
|
|
* @return void Streams response to client.
|
|
*/
|
|
public function handle_refine_from_chat($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$message = $params["topic"] ?? "";
|
|
$selected_block = $params["selectedBlockClientId"] ?? "";
|
|
$post_id = $params["postId"] ?? 0;
|
|
$session_id = $this->resolve_or_create_session_id(
|
|
$params["sessionId"] ?? "",
|
|
$post_id,
|
|
);
|
|
$blocks_to_refine = $params["blocksToRefine"] ?? [];
|
|
$all_blocks = $params["allBlocks"] ?? [];
|
|
$diff_plan = !empty($params["diffPlan"]);
|
|
$selective_refine = !empty($params["selectiveRefine"]);
|
|
$audit_context =
|
|
isset($params["auditContext"]) && is_array($params["auditContext"])
|
|
? $params["auditContext"]
|
|
: [];
|
|
|
|
if (empty($blocks_to_refine) || !is_array($blocks_to_refine)) {
|
|
return new WP_Error(
|
|
"no_blocks_mentioned",
|
|
__(
|
|
"No valid blocks found to refine. Try mentioning blocks like @this, @previous, or specific blocks like @paragraph-1",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
// Check post permission BEFORE reading post data.
|
|
if ($post_id > 0 && !$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
// Only read post config after permission check.
|
|
$post_config = $this->resolve_post_config_from_request(
|
|
$params,
|
|
$post_id,
|
|
);
|
|
|
|
// Stream refinement for each mentioned block
|
|
$this->stream_refinement_from_chat(
|
|
$blocks_to_refine,
|
|
$message,
|
|
$selected_block,
|
|
$post_id,
|
|
$all_blocks,
|
|
$diff_plan,
|
|
$post_config,
|
|
$session_id,
|
|
$selective_refine,
|
|
$audit_context,
|
|
);
|
|
|
|
// Return early to avoid REST API trying to send headers after streaming
|
|
exit();
|
|
}
|
|
|
|
/**
|
|
* Save section-to-block mapping.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_save_section_blocks($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$post_id = intval($params["postId"] ?? 0);
|
|
$section_id = sanitize_text_field($params["sectionId"] ?? "");
|
|
$block_ids = $params["blockIds"] ?? [];
|
|
|
|
if ($post_id <= 0 || empty($section_id) || !is_array($block_ids)) {
|
|
return new WP_Error(
|
|
"invalid_section_blocks",
|
|
__(
|
|
"Invalid section block mapping request.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
if (!$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$block_ids = array_values(
|
|
array_filter(array_map("sanitize_text_field", $block_ids)),
|
|
);
|
|
|
|
$mapping = get_post_meta($post_id, "_wpaw_section_blocks", true);
|
|
if (!is_array($mapping)) {
|
|
$mapping = [];
|
|
}
|
|
|
|
$mapping[$section_id] = $block_ids;
|
|
update_post_meta($post_id, "_wpaw_section_blocks", $mapping);
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"success" => true,
|
|
"sectionId" => $section_id,
|
|
"blockCount" => count($block_ids),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get section-to-block mapping for a post.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_get_section_blocks($request)
|
|
{
|
|
$post_id = intval($request["post_id"] ?? 0);
|
|
if ($post_id <= 0) {
|
|
return new WP_Error(
|
|
"invalid_post",
|
|
__("Invalid post ID.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
if (!$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to access this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$mapping = get_post_meta($post_id, "_wpaw_section_blocks", true);
|
|
if (!is_array($mapping)) {
|
|
$mapping = [];
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"sectionBlocks" => $mapping,
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Stream block refinement from chat to client.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $blocks_to_refine Array of block objects to refine (from editor).
|
|
* @param string $message User's refinement message.
|
|
* @param string $selected_block Currently selected block client ID.
|
|
* @param int $post_id Post ID.
|
|
* @return void Streams response to client.
|
|
*/
|
|
public function stream_refinement_from_chat(
|
|
$blocks_to_refine,
|
|
$message,
|
|
$selected_block,
|
|
$post_id,
|
|
$all_blocks,
|
|
$diff_plan,
|
|
$post_config = [],
|
|
$session_id = "",
|
|
$selective_refine = false,
|
|
$audit_context = [],
|
|
) {
|
|
// Set headers for streaming.
|
|
header("Content-Type: text/event-stream");
|
|
header("Cache-Control: no-cache");
|
|
header("X-Accel-Buffering: no"); // Disable Nginx buffering.
|
|
|
|
// Flush output buffer to ensure immediate streaming.
|
|
if (ob_get_level() > 0) {
|
|
ob_end_flush();
|
|
}
|
|
flush();
|
|
|
|
try {
|
|
if ($post_id > 0) {
|
|
$this->update_post_memory($post_id, [
|
|
"last_prompt" => $message,
|
|
"last_intent" => "refine",
|
|
]);
|
|
}
|
|
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"writing",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$post_config = $this->sanitize_post_config(
|
|
wp_parse_args($post_config, $this->get_default_post_config()),
|
|
);
|
|
$post_config_context = $this->build_post_config_context(
|
|
$post_config,
|
|
);
|
|
$stored_language = get_post_meta(
|
|
$post_id,
|
|
"_wpaw_detected_language",
|
|
true,
|
|
);
|
|
$effective_language = $this->resolve_language_preference(
|
|
$post_config,
|
|
$stored_language,
|
|
);
|
|
$language_instruction = $this->build_language_instruction(
|
|
$effective_language,
|
|
"refined content",
|
|
);
|
|
$refined_count = 0;
|
|
$total_cost = 0.0;
|
|
$failed_count = 0;
|
|
$consecutive_errors = 0;
|
|
$max_consecutive_errors = 3;
|
|
$aborted_due_to_provider_errors = false;
|
|
$last_model_used = "";
|
|
$total_blocks_to_refine = is_array($blocks_to_refine)
|
|
? count($blocks_to_refine)
|
|
: 0;
|
|
$batch_size = 5;
|
|
$batch_total =
|
|
$total_blocks_to_refine > 0
|
|
? (int) ceil($total_blocks_to_refine / $batch_size)
|
|
: 0;
|
|
|
|
// Get post title for context
|
|
$post = get_post($post_id);
|
|
$post_title = $post ? $post->post_title : "Unknown";
|
|
|
|
// Normalize blocks for context
|
|
$context_blocks = [];
|
|
$block_source =
|
|
is_array($all_blocks) && !empty($all_blocks)
|
|
? $all_blocks
|
|
: $this->select_blocks();
|
|
$allowed_block_ids = array_values(
|
|
array_filter(
|
|
array_map(
|
|
static function ($block_obj) {
|
|
return sanitize_text_field(
|
|
$block_obj["clientId"] ?? "",
|
|
);
|
|
},
|
|
is_array($blocks_to_refine) ? $blocks_to_refine : [],
|
|
),
|
|
),
|
|
);
|
|
foreach ($block_source as $block) {
|
|
$client_id =
|
|
$block["clientId"] ?? ($block["attrs"]["clientId"] ?? "");
|
|
$block_type =
|
|
$block["name"] ?? ($block["blockName"] ?? "core/paragraph");
|
|
$block_attrs = $block["attributes"] ?? ($block["attrs"] ?? []);
|
|
$content = $this->extract_block_content_from_attrs(
|
|
$block_type,
|
|
$block_attrs,
|
|
);
|
|
|
|
if (empty($client_id)) {
|
|
continue;
|
|
}
|
|
|
|
$context_blocks[] = [
|
|
"clientId" => $client_id,
|
|
"type" => $block_type,
|
|
"content" => $content,
|
|
];
|
|
}
|
|
|
|
// Optional evaluator pass: classify blocks first, then refine only necessary ones.
|
|
if ($selective_refine && count($blocks_to_refine) > 1) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"message" => sprintf(
|
|
"Evaluating %d block(s) to select only necessary refinements...",
|
|
count($blocks_to_refine),
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
$eval_map = [];
|
|
foreach ($blocks_to_refine as $block_obj) {
|
|
$cid = sanitize_text_field($block_obj["clientId"] ?? "");
|
|
$bname = sanitize_text_field(
|
|
$block_obj["name"] ?? "core/paragraph",
|
|
);
|
|
$battrs = $block_obj["attributes"] ?? [];
|
|
$txt = trim(
|
|
wp_strip_all_tags(
|
|
$this->extract_block_content_from_attrs(
|
|
$bname,
|
|
$battrs,
|
|
),
|
|
),
|
|
);
|
|
if ("" === $cid || "" === $txt) {
|
|
continue;
|
|
}
|
|
if (strlen($txt) > 220) {
|
|
$txt = substr($txt, 0, 220) . "...";
|
|
}
|
|
$eval_map[] = [
|
|
"blockId" => $cid,
|
|
"type" => $bname,
|
|
"text" => $txt,
|
|
];
|
|
}
|
|
|
|
if (!empty($eval_map)) {
|
|
$eval_prompt =
|
|
"You are a strict editor classifier.\n" .
|
|
"Task: decide which blocks NEED refinement for this user request.\n" .
|
|
"Return ONLY JSON: {\"keep\":[\"id\"],\"needs_refine\":[\"id\"],\"reasons\":{\"id\":\"short reason\"}}\n" .
|
|
"Rules: If block already satisfies request, keep it. Do not rewrite. Only classify.\n" .
|
|
"User request: {$message}\nBlocks:\n";
|
|
foreach ($eval_map as $row) {
|
|
$eval_prompt .= "- {$row["blockId"]} | {$row["type"]} | {$row["text"]}\n";
|
|
}
|
|
|
|
$eval_response = $provider->chat(
|
|
[
|
|
["role" => "system", "content" => $eval_prompt],
|
|
["role" => "user", "content" => "Classify now."],
|
|
],
|
|
["temperature" => 0.1],
|
|
"planning",
|
|
);
|
|
|
|
if (!is_wp_error($eval_response)) {
|
|
$eval_raw = trim(
|
|
(string) ($eval_response["content"] ?? ""),
|
|
);
|
|
if (
|
|
preg_match(
|
|
'/```(?:json)?\s*\n?(.*?)\n?```/s',
|
|
$eval_raw,
|
|
$m,
|
|
)
|
|
) {
|
|
$eval_raw = trim($m[1]);
|
|
}
|
|
$eval_json = json_decode($eval_raw, true);
|
|
if (
|
|
is_array($eval_json) &&
|
|
isset($eval_json["needs_refine"]) &&
|
|
is_array($eval_json["needs_refine"])
|
|
) {
|
|
$needs_lookup = array_fill_keys(
|
|
array_map(
|
|
"sanitize_text_field",
|
|
$eval_json["needs_refine"],
|
|
),
|
|
true,
|
|
);
|
|
$filtered = array_values(
|
|
array_filter(
|
|
$blocks_to_refine,
|
|
static function ($block_obj) use (
|
|
$needs_lookup,
|
|
) {
|
|
$cid = sanitize_text_field(
|
|
$block_obj["clientId"] ?? "",
|
|
);
|
|
return isset($needs_lookup[$cid]);
|
|
},
|
|
),
|
|
);
|
|
if (!empty($filtered)) {
|
|
$before_count = count($blocks_to_refine);
|
|
$blocks_to_refine = $filtered;
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"message" => sprintf(
|
|
'Selective refinement: %1$d/%2$d block(s) need updates.',
|
|
count($blocks_to_refine),
|
|
$before_count,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($diff_plan && !empty($context_blocks)) {
|
|
$plan_generation_failed = false;
|
|
$plan_prompt =
|
|
"You are an editor planning precise block-level edits.
|
|
|
|
Return ONLY valid JSON in this format:
|
|
{
|
|
\"summary\": \"short summary\",
|
|
\"actions\": [
|
|
{\"action\": \"keep\", \"blockId\": \"...\"},
|
|
{\"action\": \"replace\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
|
|
{\"action\": \"insert_after\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
|
|
{\"action\": \"insert_before\", \"blockId\": \"...\", \"blockType\": \"core/paragraph\", \"content\": \"...\"},
|
|
{\"action\": \"delete\", \"blockId\": \"...\"},
|
|
{\"action\": \"change_type\", \"blockId\": \"...\", \"blockType\": \"core/list\", \"content\": \"...\"}
|
|
]
|
|
}
|
|
|
|
Rules:
|
|
- Keep actions minimal.
|
|
- Use blockId from the provided list only.
|
|
- If you need code, use blockType core/code and content with code only.
|
|
- For lists, use blockType core/list and content as one item per line.
|
|
- For headings, use blockType core/heading.
|
|
- For images, use blockType core/image and content as markdown image: .
|
|
- No explanations, no extra text, JSON only.
|
|
|
|
User request: {$message}
|
|
|
|
Allowed target block IDs (STRICT): " .
|
|
implode(", ", $allowed_block_ids) .
|
|
"
|
|
|
|
Blocks:
|
|
";
|
|
|
|
foreach ($context_blocks as $index => $block) {
|
|
$plan_prompt .=
|
|
$index +
|
|
1 .
|
|
". {$block["clientId"]} | {$block["type"]} | " .
|
|
$block["content"] .
|
|
"\n";
|
|
}
|
|
|
|
$plan_response = $provider->chat(
|
|
[
|
|
["role" => "system", "content" => $plan_prompt],
|
|
[
|
|
"role" => "user",
|
|
"content" => "Create the edit plan now.",
|
|
],
|
|
],
|
|
["temperature" => 0.2],
|
|
"planning",
|
|
);
|
|
|
|
if (!is_wp_error($plan_response)) {
|
|
// Track cost for edit plan generation
|
|
$plan_cost = $plan_response["cost"] ?? 0;
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$plan_response["model"] ?? "",
|
|
"refinement_planning",
|
|
$plan_response["input_tokens"] ?? 0,
|
|
$plan_response["output_tokens"] ?? 0,
|
|
$plan_cost,
|
|
$provider_result,
|
|
$session_id ?? "",
|
|
"success",
|
|
);
|
|
|
|
$raw_content = trim($plan_response["content"]);
|
|
error_log(
|
|
"WP Agentic Writer: Edit plan raw response: " .
|
|
substr($raw_content, 0, 500),
|
|
);
|
|
|
|
// Strip markdown code blocks if present (```json ... ```)
|
|
$json_content = $raw_content;
|
|
if (
|
|
preg_match(
|
|
'/```(?:json)?\s*\n?(.*?)\n?```/s',
|
|
$raw_content,
|
|
$matches,
|
|
)
|
|
) {
|
|
$json_content = trim($matches[1]);
|
|
error_log(
|
|
"WP Agentic Writer: Extracted JSON from markdown code block",
|
|
);
|
|
}
|
|
|
|
$plan_json = json_decode($json_content, true);
|
|
if (is_array($plan_json) && isset($plan_json["actions"])) {
|
|
$plan_json = $this->sanitize_refinement_edit_plan(
|
|
$plan_json,
|
|
$allowed_block_ids,
|
|
$context_blocks,
|
|
);
|
|
if (empty($plan_json["actions"])) {
|
|
$plan_generation_failed = true;
|
|
} else {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "edit_plan",
|
|
"plan" => $plan_json,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
exit();
|
|
}
|
|
} else {
|
|
error_log(
|
|
"WP Agentic Writer: Edit plan JSON decode failed or missing actions. JSON error: " .
|
|
json_last_error_msg(),
|
|
);
|
|
error_log(
|
|
"WP Agentic Writer: Attempted to parse: " .
|
|
substr($json_content, 0, 200),
|
|
);
|
|
$plan_generation_failed = true;
|
|
}
|
|
} else {
|
|
error_log(
|
|
"WP Agentic Writer: Edit plan API error: " .
|
|
$plan_response->get_error_message(),
|
|
);
|
|
$plan_generation_failed = true;
|
|
}
|
|
|
|
// Fallback path: when edit-plan fails (common on broad @all requests),
|
|
// continue with direct per-block refinement instead of hard failing.
|
|
if ($plan_generation_failed) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"message" =>
|
|
"Edit plan failed, switching to direct block refinement.",
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
|
|
foreach ($blocks_to_refine as $block_index_loop => $block_obj) {
|
|
if (0 === $block_index_loop % $batch_size) {
|
|
$current_batch =
|
|
(int) floor($block_index_loop / $batch_size) + 1;
|
|
$batch_start = $block_index_loop + 1;
|
|
$batch_end = min(
|
|
$total_blocks_to_refine,
|
|
$block_index_loop + $batch_size,
|
|
);
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"message" => sprintf(
|
|
'Processing batch %1$d/%2$d (blocks %3$d-%4$d of %5$d)',
|
|
$current_batch,
|
|
$batch_total,
|
|
$batch_start,
|
|
$batch_end,
|
|
$total_blocks_to_refine,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
// Extract block data from the block object sent from frontend
|
|
$block_client_id = $block_obj["clientId"] ?? "";
|
|
$block_type = $block_obj["name"] ?? "core/paragraph";
|
|
$block_attrs = $block_obj["attributes"] ?? [];
|
|
$block_content = $this->extract_block_content_from_attrs(
|
|
$block_type,
|
|
$block_attrs,
|
|
);
|
|
|
|
// Find block index in all blocks for context
|
|
$block_index = -1;
|
|
foreach ($all_blocks as $i => $block) {
|
|
if (
|
|
isset($block["clientId"]) &&
|
|
$block["clientId"] === $block_client_id
|
|
) {
|
|
$block_index = $i;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Build article context
|
|
$article_context = [
|
|
"title" => $post_title,
|
|
"previousBlock" =>
|
|
$block_index > 0
|
|
? $this->extract_heading_from_block(
|
|
$all_blocks[$block_index - 1],
|
|
)
|
|
: null,
|
|
"nextBlock" =>
|
|
$block_index >= 0 &&
|
|
$block_index < count($all_blocks) - 1
|
|
? $this->extract_heading_from_block(
|
|
$all_blocks[$block_index + 1],
|
|
)
|
|
: null,
|
|
];
|
|
|
|
// Build refinement prompt
|
|
$memory_context = $this->get_post_memory_context($post_id);
|
|
$context_str = "\n\nArticle Context:\n";
|
|
$context_str .= "Title: " . $post_title . "\n";
|
|
|
|
if (!empty($article_context["previousBlock"])) {
|
|
$context_str .=
|
|
"Previous section: " .
|
|
$article_context["previousBlock"]["heading"] .
|
|
"\n";
|
|
}
|
|
|
|
$context_str .= "Current block type: " . $block_type . "\n";
|
|
$context_str .= "Current content:\n" . $block_content . "\n";
|
|
$section_context = $this->build_section_context_for_block(
|
|
$all_blocks,
|
|
$block_index,
|
|
4,
|
|
);
|
|
if (!empty($section_context)) {
|
|
$context_str .=
|
|
"Section context:\n" . $section_context . "\n";
|
|
}
|
|
|
|
if (!empty($article_context["nextBlock"])) {
|
|
$context_str .=
|
|
"Next section: " .
|
|
$article_context["nextBlock"]["heading"] .
|
|
"\n";
|
|
}
|
|
|
|
$system_prompt = "You are a precise content editor. Your task is to refine the provided content based strictly on the user's request.
|
|
|
|
ANTI-ROBOT RULES:
|
|
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament.
|
|
- Do not add introductory throat-clearing sentences or summarizing conclusions unless explicitly requested.
|
|
- Increase specificity and information density. Do not just increase word count with conversational filler.
|
|
|
|
CRITICAL LANGUAGE REQUIREMENT:
|
|
{$language_instruction}
|
|
{$post_config_context}
|
|
|
|
{$context_str}
|
|
{$memory_context}
|
|
|
|
USER REQUEST: {$message}
|
|
|
|
IMPORTANT RULES:
|
|
1. Rewrite the content to fulfill the refinement request
|
|
2. Maintain the core meaning and key information
|
|
3. Ensure it flows well with surrounding sections
|
|
4. Match the article's overall tone and style
|
|
5. Return ONLY the refined content payload, no explanations or conversational text
|
|
|
|
Output format:
|
|
- Return STRICT JSON ONLY: {\"content\":\"...\",\"blockType\":\"{$block_type}\"}
|
|
- Use content value only for the final refined block text
|
|
- If list: content should contain one item per line
|
|
- No markdown wrappers, no chain-of-thought, no \"Refined version\", no \"Key refinements\", no explanations";
|
|
|
|
$messages = [
|
|
[
|
|
"role" => "system",
|
|
"content" => $system_prompt,
|
|
],
|
|
[
|
|
"role" => "user",
|
|
"content" => "Refine this content.",
|
|
],
|
|
];
|
|
|
|
// Use streaming for real-time feedback
|
|
$refined_content = "";
|
|
$stream_result = $provider->chat_stream(
|
|
$messages,
|
|
["temperature" => 0.2],
|
|
"execution",
|
|
function ($chunk) use (&$refined_content) {
|
|
// Accumulate the streaming content
|
|
$refined_content .= $chunk;
|
|
},
|
|
);
|
|
|
|
if (is_wp_error($stream_result)) {
|
|
$failed_count++;
|
|
$consecutive_errors++;
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $stream_result->get_error_message(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
if ($consecutive_errors >= $max_consecutive_errors) {
|
|
$aborted_due_to_provider_errors = true;
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"message" => sprintf(
|
|
"Stopped early after %d consecutive provider failures at block %d. Please retry with fewer blocks or check local backend health.",
|
|
(int) $consecutive_errors,
|
|
(int) $block_index_loop + 1,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
break;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
$consecutive_errors = 0;
|
|
|
|
// Track cost from streaming result (always track for debugging).
|
|
$stream_cost = $stream_result["cost"] ?? 0;
|
|
$last_model_used = $stream_result["model"] ?? $last_model_used;
|
|
$total_cost += $stream_cost;
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$stream_result["model"] ?? "",
|
|
"block_refinement",
|
|
$stream_result["input_tokens"] ?? 0,
|
|
$stream_result["output_tokens"] ?? 0,
|
|
$stream_cost,
|
|
$provider_result,
|
|
$session_id ?? "",
|
|
"success",
|
|
);
|
|
|
|
// Parse and clean the response
|
|
$payload = $this->parse_refined_payload($refined_content);
|
|
$refined_content = $this->clean_refined_content(
|
|
$payload["content"],
|
|
);
|
|
$resolved_block_type = $payload["blockType"] ?? $block_type;
|
|
if (
|
|
$this->is_contaminated_refinement_output(
|
|
$refined_content,
|
|
$resolved_block_type,
|
|
)
|
|
) {
|
|
$failed_count++;
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"message" => sprintf(
|
|
'Skipped contaminated output for block %1$d/%2$d (%3$s).',
|
|
(int) $block_index_loop + 1,
|
|
(int) $total_blocks_to_refine,
|
|
$resolved_block_type,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
continue;
|
|
}
|
|
|
|
// Create proper block structure
|
|
$block_structure = $this->create_block_structure(
|
|
$block_client_id,
|
|
$resolved_block_type,
|
|
$refined_content,
|
|
);
|
|
|
|
// Send the refined block
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "block",
|
|
"block" => $block_structure,
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
|
|
$refined_count++;
|
|
if (
|
|
0 === $refined_count % 5 ||
|
|
$refined_count === $total_blocks_to_refine
|
|
) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "status",
|
|
"message" => sprintf(
|
|
'Progress: %1$d/%2$d block(s) updated (%3$d failed)',
|
|
$refined_count,
|
|
$total_blocks_to_refine,
|
|
$failed_count,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
|
|
// Small delay between blocks
|
|
usleep(100000);
|
|
}
|
|
|
|
// Persist refinement exchange in session history so msg counts remain accurate.
|
|
if (!empty($session_id) && !empty($message)) {
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$context_service->add_message($session_id, [
|
|
"role" => "user",
|
|
"content" => sanitize_text_field($message),
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
$context_service->add_message($session_id, [
|
|
"role" => "assistant",
|
|
"content" =>
|
|
isset($audit_context["source"]) &&
|
|
"seo_audit" ===
|
|
sanitize_text_field(
|
|
(string) $audit_context["source"],
|
|
)
|
|
? sprintf(
|
|
'Audit fix complete: %1$s; %2$d candidate block(s) inspected; %3$d block(s) changed.',
|
|
!empty($audit_context["patternCount"])
|
|
? sprintf(
|
|
"%d pattern occurrence(s)",
|
|
(int) $audit_context["patternCount"],
|
|
)
|
|
: "audit pattern occurrence(s)",
|
|
isset($audit_context["candidateBlockCount"])
|
|
? (int) $audit_context[
|
|
"candidateBlockCount"
|
|
]
|
|
: count($blocks_to_refine),
|
|
(int) $refined_count,
|
|
)
|
|
: sprintf(
|
|
"Refined %d block(s) based on your request.",
|
|
(int) $refined_count,
|
|
),
|
|
"timestamp" => current_time("c"),
|
|
]);
|
|
}
|
|
|
|
// Send completion message with provider metadata.
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "complete",
|
|
"refined" => $refined_count,
|
|
"failed" => $failed_count,
|
|
"aborted" => $aborted_due_to_provider_errors,
|
|
"totalCost" => $total_cost,
|
|
"provider_metadata" => $this->build_provider_metadata(
|
|
$provider_result,
|
|
$last_model_used,
|
|
),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
} catch (Exception $e) {
|
|
echo "data: " .
|
|
wp_json_encode([
|
|
"type" => "error",
|
|
"message" => $e->getMessage(),
|
|
]) .
|
|
"\n\n";
|
|
flush();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restrict model edit-plan actions to explicitly allowed target block IDs.
|
|
*
|
|
* @since 0.1.0
|
|
*
|
|
* @param array $plan_json Parsed model plan response.
|
|
* @param array $allowed_block_ids Block IDs allowed to be edited.
|
|
* @param array $context_blocks Normalized block context list.
|
|
* @return array Sanitized plan response.
|
|
*/
|
|
/**
|
|
* Sanitize refinement edit plan.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::sanitize_refinement_edit_plan() instead.
|
|
* @param array $plan_json Edit plan.
|
|
* @param array $allowed_block_ids Allowed IDs.
|
|
* @param array $context_blocks Context blocks.
|
|
* @return array
|
|
*/
|
|
private function sanitize_refinement_edit_plan(
|
|
$plan_json,
|
|
$allowed_block_ids,
|
|
$context_blocks,
|
|
) {
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::sanitize_refinement_edit_plan(
|
|
$plan_json,
|
|
$allowed_block_ids,
|
|
$context_blocks,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find block by client ID in parsed blocks array.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $blocks Parsed blocks array.
|
|
* @param string $client_id Block client ID to find.
|
|
* @return array|null Block data or null if not found.
|
|
*/
|
|
/**
|
|
* Find block by client ID (recursive).
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::find_block_by_client_id() instead.
|
|
* @param array $blocks Parsed blocks.
|
|
* @param string $client_id Client ID to find.
|
|
* @return array|null
|
|
*/
|
|
private function find_block_by_client_id($blocks, $client_id)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::find_block_by_client_id(
|
|
$blocks,
|
|
$client_id,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Find block index in parsed blocks array.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $blocks Parsed blocks array.
|
|
* @param string $client_id Block client ID to find.
|
|
* @return int Block index or -1 if not found.
|
|
*/
|
|
/**
|
|
* Find block index by client ID.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::find_block_index() instead.
|
|
* @param array $blocks Parsed blocks.
|
|
* @param string $client_id Client ID to find.
|
|
* @return int
|
|
*/
|
|
private function find_block_index($blocks, $client_id)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::find_block_index(
|
|
$blocks,
|
|
$client_id,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Extract content from block data.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $block Block data.
|
|
* @return string Block content.
|
|
*/
|
|
/**
|
|
* Extract block content.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_block_content() instead.
|
|
* @param array $block Block data.
|
|
* @return string
|
|
*/
|
|
private function extract_block_content($block)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::extract_block_content($block);
|
|
}
|
|
|
|
/**
|
|
* Extract heading from block.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $block Block data.
|
|
* @return array|null Heading data or null if not a heading.
|
|
*/
|
|
/**
|
|
* Extract heading from block.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_heading_from_block() instead.
|
|
* @param array $block Block data.
|
|
* @return array|null
|
|
*/
|
|
private function extract_heading_from_block($block)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::extract_heading_from_block(
|
|
$block,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Clean refined content by removing conversational text.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $content Content to clean.
|
|
* @return string Cleaned content.
|
|
*/
|
|
/**
|
|
* Clean refined content.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::clean_refined_content() instead.
|
|
* @param string $content Content to clean.
|
|
* @return string
|
|
*/
|
|
private function clean_refined_content($content)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::clean_refined_content(
|
|
$content,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Parse refined payload that may be wrapped in JSON.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $content Raw model content.
|
|
* @return array Parsed payload with content and optional blockType.
|
|
*/
|
|
/**
|
|
* Parse refined payload.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::parse_refined_payload() instead.
|
|
* @param string $content Raw content.
|
|
* @return array
|
|
*/
|
|
private function parse_refined_payload($content)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::parse_refined_payload(
|
|
$content,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Detect assistant/meta chatter that should never be inserted as block content.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $content Refined content candidate.
|
|
* @param string $block_type Resolved block type.
|
|
* @return bool
|
|
*/
|
|
/**
|
|
* Check if content is contaminated refinement output.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::is_contaminated_refinement_output() instead.
|
|
* @param string $content Content to check.
|
|
* @param string $block_type Block type.
|
|
* @return bool
|
|
*/
|
|
private function is_contaminated_refinement_output(
|
|
$content,
|
|
$block_type = "core/paragraph",
|
|
) {
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::is_contaminated_refinement_output(
|
|
$content,
|
|
$block_type,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a compact section-scoped context window around a block.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $all_blocks All serialized editor blocks.
|
|
* @param int $block_index Current block index.
|
|
* @param int $max_snippets Max context snippets.
|
|
* @return string
|
|
*/
|
|
/**
|
|
* Build section context for block.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::build_section_context_for_block() instead.
|
|
* @param array $all_blocks All blocks.
|
|
* @param int $block_index Block index.
|
|
* @param int $max_snippets Max snippets.
|
|
* @return string
|
|
*/
|
|
private function build_section_context_for_block(
|
|
$all_blocks,
|
|
$block_index,
|
|
$max_snippets = 4,
|
|
) {
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::build_section_context_for_block(
|
|
$all_blocks,
|
|
$block_index,
|
|
$max_snippets,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Create block structure for refined content.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $block_id Block client ID.
|
|
* @param string $block_type Block type.
|
|
* @param string $content Refined content.
|
|
* @return array Block structure.
|
|
*/
|
|
/**
|
|
* Create block structure.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::create_block_structure() instead.
|
|
* @param string $block_id Block ID.
|
|
* @param string $block_type Block type.
|
|
* @param string $content Content.
|
|
* @return array
|
|
*/
|
|
private function create_block_structure($block_id, $block_type, $content)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::create_block_structure(
|
|
$block_id,
|
|
$block_type,
|
|
$content,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build a short memory summary from the plan JSON.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $plan_json Plan data.
|
|
* @return string Summary text.
|
|
*/
|
|
/**
|
|
* Build memory summary from plan.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan() instead.
|
|
* @param array $plan_json Plan data.
|
|
* @return string
|
|
*/
|
|
private function build_memory_summary_from_plan($plan_json)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::build_memory_summary_from_plan(
|
|
$plan_json,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Update per-post memory meta.
|
|
*
|
|
* @since 0.1.0
|
|
* @param int $post_id Post ID.
|
|
* @param array $data Memory fields to update.
|
|
* @return void
|
|
*/
|
|
public function update_post_memory($post_id, $data)
|
|
{
|
|
if ($post_id <= 0) {
|
|
return;
|
|
}
|
|
|
|
$memory = get_post_meta($post_id, "_wpaw_memory", true);
|
|
if (!is_array($memory)) {
|
|
$memory = [];
|
|
}
|
|
|
|
$memory = array_merge($memory, $data);
|
|
$memory["updated_at"] = current_time("timestamp");
|
|
|
|
update_post_meta($post_id, "_wpaw_memory", $memory);
|
|
}
|
|
|
|
/**
|
|
* Build memory context string for prompts.
|
|
*
|
|
* @since 0.1.0
|
|
* @param int $post_id Post ID.
|
|
* @return string Context string.
|
|
*/
|
|
/**
|
|
* Get post memory context.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::get_post_memory_context() instead.
|
|
* @param int $post_id Post ID.
|
|
* @return string
|
|
*/
|
|
public function get_post_memory_context($post_id)
|
|
{
|
|
$bundle = $this->get_post_context_bundle($post_id);
|
|
$memory = $bundle["memory"];
|
|
|
|
if (empty($memory) || !is_array($memory)) {
|
|
return "";
|
|
}
|
|
|
|
$lines = [];
|
|
if (!empty($memory["summary"])) {
|
|
$lines[] = "Summary: " . $memory["summary"];
|
|
}
|
|
if (!empty($memory["last_prompt"])) {
|
|
$lines[] = "Last prompt: " . $memory["last_prompt"];
|
|
}
|
|
if (!empty($memory["last_intent"])) {
|
|
$lines[] = "Last intent: " . $memory["last_intent"];
|
|
}
|
|
|
|
if (empty($lines)) {
|
|
return "";
|
|
}
|
|
|
|
return "\n\n=== POST MEMORY ===\n" .
|
|
implode("\n", $lines) .
|
|
"\n=== END POST MEMORY ===\n";
|
|
}
|
|
|
|
/**
|
|
* Get blocks from the current editor state.
|
|
*
|
|
* @since 0.1.0
|
|
* @return array Array of block objects from editor.
|
|
*/
|
|
/**
|
|
* Select blocks from post.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::select_blocks() instead.
|
|
* @return array
|
|
*/
|
|
private function select_blocks()
|
|
{
|
|
global $post;
|
|
if (!$post) {
|
|
return [];
|
|
}
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::select_blocks($post->ID);
|
|
}
|
|
|
|
/**
|
|
* Serialize block object for consistent handling.
|
|
*
|
|
* @since 0.1.0
|
|
* @param array $block Block data.
|
|
* @return array Serialized block with clientId.
|
|
*/
|
|
/**
|
|
* Serialize block.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::serialize_block() instead.
|
|
* @param array $block Block data.
|
|
* @return array
|
|
*/
|
|
private function serialize_block($block)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::serialize_block($block);
|
|
}
|
|
|
|
/**
|
|
* Extract content from block attributes.
|
|
*
|
|
* @since 0.1.0
|
|
* @param string $block_type Block type (e.g., 'core/paragraph').
|
|
* @param array $attrs Block attributes.
|
|
* @return string Extracted content.
|
|
*/
|
|
/**
|
|
* Extract block content from attrs.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_block_content_from_attrs() instead.
|
|
* @param string $block_type Block type.
|
|
* @param array $attrs Block attributes.
|
|
* @return string
|
|
*/
|
|
public function extract_block_content_from_attrs($block_type, $attrs)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::extract_block_content_from_attrs(
|
|
$block_type,
|
|
$attrs,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle SEO audit request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
/**
|
|
* Scan post content for common AI-ish writing patterns.
|
|
*
|
|
* @param string $raw_content Raw post content.
|
|
* @return array{count:int,examples:array}
|
|
*/
|
|
/**
|
|
* Scan for AI-ish patterns.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::scan_ai_ish_patterns() instead.
|
|
* @param string $raw_content Raw content.
|
|
* @return array
|
|
*/
|
|
public function scan_ai_ish_patterns($raw_content)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::scan_ai_ish_patterns(
|
|
$raw_content,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Refine current post title based on user instruction.
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_refine_title($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$post_id = isset($params["postId"]) ? (int) $params["postId"] : 0;
|
|
$instruction = sanitize_text_field($params["instruction"] ?? "");
|
|
$session_id = sanitize_text_field($params["sessionId"] ?? "");
|
|
|
|
if ($post_id <= 0) {
|
|
return new WP_Error(
|
|
"invalid_post",
|
|
__("Invalid post ID.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
if (!$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
if ("" === $instruction) {
|
|
return new WP_Error(
|
|
"missing_instruction",
|
|
__("Title instruction is required.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
$post = get_post($post_id);
|
|
if (!$post) {
|
|
return new WP_Error(
|
|
"post_not_found",
|
|
__("Post not found.", "wp-agentic-writer"),
|
|
["status" => 404],
|
|
);
|
|
}
|
|
|
|
$current_title = trim(wp_strip_all_tags((string) $post->post_title));
|
|
$post_config = $this->get_post_config($post_id);
|
|
$focus_keyword = trim(
|
|
(string) ($post_config["seo_focus_keyword"] ?? ""),
|
|
);
|
|
|
|
$system_prompt =
|
|
"You are an expert SEO copy editor for article titles.\n" .
|
|
"Rewrite the title based on instruction.\n" .
|
|
"Return ONLY the final title text.\n" .
|
|
"No quotes. No explanation. No markdown.";
|
|
$user_prompt =
|
|
"Current title: " .
|
|
("" !== $current_title ? $current_title : "(empty)") .
|
|
"\n" .
|
|
"Focus keyword: " .
|
|
("" !== $focus_keyword ? $focus_keyword : "(not set)") .
|
|
"\n" .
|
|
"Instruction: " .
|
|
$instruction .
|
|
"\n" .
|
|
"Constraints: keep it concise, natural, and publish-ready.";
|
|
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"refinement",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$response = $provider->chat(
|
|
[
|
|
["role" => "system", "content" => $system_prompt],
|
|
["role" => "user", "content" => $user_prompt],
|
|
],
|
|
["post_id" => $post_id],
|
|
"refinement",
|
|
);
|
|
|
|
if (is_wp_error($response)) {
|
|
return $response;
|
|
}
|
|
|
|
$new_title = trim(
|
|
wp_strip_all_tags((string) ($response["content"] ?? "")),
|
|
);
|
|
$new_title = preg_replace("/\s+/", " ", $new_title);
|
|
|
|
if ("" === $new_title) {
|
|
return new WP_Error(
|
|
"empty_title",
|
|
__("Refined title is empty.", "wp-agentic-writer"),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
wp_update_post([
|
|
"ID" => $post_id,
|
|
"post_title" => $new_title,
|
|
]);
|
|
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"title_refinement",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$response["cost"] ?? 0,
|
|
$provider_result,
|
|
$session_id,
|
|
"success",
|
|
);
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"title" => $new_title,
|
|
"cost" => $response["cost"] ?? 0,
|
|
"provider_metadata" => $this->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Suggest relevant internal links based on content similarity.
|
|
*
|
|
* @since 0.1.0
|
|
* @param int $post_id Current post ID.
|
|
* @param string $focus_keyword Focus keyword.
|
|
* @param int $limit Maximum number of suggestions.
|
|
* @return array Array of suggested posts with title and URL.
|
|
*/
|
|
public function suggest_internal_links(
|
|
$post_id,
|
|
$focus_keyword = "",
|
|
$limit = 3,
|
|
) {
|
|
$suggestions = [];
|
|
|
|
// Get all published posts except current
|
|
$args = [
|
|
"post_type" => "post",
|
|
"post_status" => "publish",
|
|
"posts_per_page" => 50,
|
|
"post__not_in" => [$post_id],
|
|
"orderby" => "date",
|
|
"order" => "DESC",
|
|
];
|
|
|
|
$posts = get_posts($args);
|
|
|
|
if (empty($posts)) {
|
|
return $suggestions;
|
|
}
|
|
|
|
foreach ($posts as $post) {
|
|
// Skip if this is the current post (safety check)
|
|
if ($post->ID === $post_id) {
|
|
continue;
|
|
}
|
|
|
|
$score = 0;
|
|
|
|
// 1. Same category (weight: 30 points per category)
|
|
$current_cats = wp_get_post_categories($post_id);
|
|
$post_cats = wp_get_post_categories($post->ID);
|
|
$cat_overlap = count(array_intersect($current_cats, $post_cats));
|
|
$score += $cat_overlap * 30;
|
|
|
|
// 2. Same tags (weight: 20 points per tag)
|
|
$current_tags = wp_get_post_tags($post_id, ["fields" => "ids"]);
|
|
$post_tags = wp_get_post_tags($post->ID, ["fields" => "ids"]);
|
|
$tag_overlap = count(array_intersect($current_tags, $post_tags));
|
|
$score += $tag_overlap * 20;
|
|
|
|
// 3. Focus keyword in title (weight: 25 points)
|
|
if (
|
|
!empty($focus_keyword) &&
|
|
stripos($post->post_title, $focus_keyword) !== false
|
|
) {
|
|
$score += 25;
|
|
}
|
|
|
|
// 4. Focus keyword in content (weight: 15 points)
|
|
if (
|
|
!empty($focus_keyword) &&
|
|
stripos($post->post_content, $focus_keyword) !== false
|
|
) {
|
|
$score += 15;
|
|
}
|
|
|
|
// 5. Recency bonus (weight: 10 points for posts < 30 days, 5 points for < 90 days)
|
|
$days_old = (time() - strtotime($post->post_date)) / DAY_IN_SECONDS;
|
|
if ($days_old < 30) {
|
|
$score += 10;
|
|
} elseif ($days_old < 90) {
|
|
$score += 5;
|
|
}
|
|
|
|
if ($score > 0) {
|
|
$suggestions[] = [
|
|
"id" => $post->ID,
|
|
"title" => $post->post_title,
|
|
"url" => get_permalink($post->ID),
|
|
"score" => $score,
|
|
];
|
|
}
|
|
}
|
|
|
|
// Sort by score descending
|
|
usort($suggestions, function ($a, $b) {
|
|
return $b["score"] - $a["score"];
|
|
});
|
|
|
|
return array_slice($suggestions, 0, $limit);
|
|
}
|
|
|
|
/**
|
|
* Auto-generate meta description after article execution.
|
|
*
|
|
* @since 0.1.0
|
|
* @param int $post_id Post ID.
|
|
* @param array $post_config Post configuration.
|
|
* @param string $effective_language Effective language.
|
|
* @return array|WP_Error Result with meta description and cost, or error.
|
|
*/
|
|
private function auto_generate_meta_description(
|
|
$post_id,
|
|
$post_config,
|
|
$effective_language,
|
|
) {
|
|
$post = get_post($post_id);
|
|
if (!$post) {
|
|
return new WP_Error("invalid_post", "Post not found");
|
|
}
|
|
|
|
$content = wp_strip_all_tags($post->post_content);
|
|
$title = $post->post_title;
|
|
$focus_keyword = $post_config["seo_focus_keyword"] ?? "";
|
|
|
|
if (empty($content)) {
|
|
return new WP_Error("no_content", "No content available");
|
|
}
|
|
|
|
$language_instruction = $this->build_language_instruction(
|
|
$effective_language,
|
|
"meta description",
|
|
);
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"clarity",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
|
|
$prompt =
|
|
"Generate a compelling meta description for SEO. Requirements:\n";
|
|
$prompt .=
|
|
"- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
|
|
$prompt .= "- Include a call-to-action or value proposition\n";
|
|
$prompt .= "- Make it enticing for searchers to click\n";
|
|
if (!empty($focus_keyword)) {
|
|
$prompt .= "- MUST include the focus keyword: \"{$focus_keyword}\"\n";
|
|
}
|
|
$prompt .= "\n{$language_instruction}\n";
|
|
$prompt .= "\nTitle: {$title}\n";
|
|
$prompt .=
|
|
"\nContent summary (first 500 chars):\n" . substr($content, 0, 500);
|
|
$prompt .=
|
|
"\n\nIMPORTANT: Your response must be 155 characters or less. Count carefully.\nRespond with ONLY the meta description text, no quotes, no explanation.";
|
|
|
|
$messages = [
|
|
[
|
|
"role" => "user",
|
|
"content" => $prompt,
|
|
],
|
|
];
|
|
|
|
$response = $provider->chat(
|
|
$messages,
|
|
["temperature" => 0.7],
|
|
"clarity",
|
|
);
|
|
|
|
if (is_wp_error($response)) {
|
|
return $response;
|
|
}
|
|
|
|
$meta_description = trim($response["content"] ?? "");
|
|
$meta_description = preg_replace(
|
|
'/^["\']|["\']$/',
|
|
"",
|
|
$meta_description,
|
|
);
|
|
|
|
// Enforce 155 character limit
|
|
if (strlen($meta_description) > 155) {
|
|
$meta_description = substr($meta_description, 0, 152) . "...";
|
|
}
|
|
|
|
// Save to post meta
|
|
update_post_meta($post_id, "_wpaw_meta_description", $meta_description);
|
|
|
|
// Track cost
|
|
$cost = $response["cost"] ?? 0;
|
|
if ($cost > 0) {
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "unknown",
|
|
"meta_description",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$cost,
|
|
$provider_result,
|
|
$session_id ?? "",
|
|
"success",
|
|
);
|
|
}
|
|
|
|
return [
|
|
"meta_description" => $meta_description,
|
|
"length" => strlen($meta_description),
|
|
"cost" => $cost,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Handle intent detection request.
|
|
*
|
|
* @since 0.1.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_detect_intent($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$last_message = $params["lastMessage"] ?? "";
|
|
$has_plan = $params["hasPlan"] ?? false;
|
|
$current_mode = $params["currentMode"] ?? "chat";
|
|
$post_id = $params["postId"] ?? 0;
|
|
|
|
// Check post permission before using postId for cost tracking.
|
|
if ($post_id > 0 && !$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to access this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
if (empty($last_message)) {
|
|
return new WP_REST_Response(["intent" => "continue_chat"], 200);
|
|
}
|
|
|
|
$normalized_message = strtolower((string) $last_message);
|
|
if (
|
|
preg_match(
|
|
"/\b(?:out?line|plan|structure|kerangka|rencana)(?:\s*[- ]?\s*(?:nya|kan))?\b/u",
|
|
$normalized_message,
|
|
)
|
|
) {
|
|
return new WP_REST_Response(
|
|
[
|
|
"intent" => "create_outline",
|
|
"cost" => 0,
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
// Build intent detection prompt
|
|
$has_plan_str = $has_plan ? "true" : "false";
|
|
$prompt = "Based on the user's message, determine their intent. Choose ONE:
|
|
|
|
1. \"create_outline\" - User wants to create an article outline/structure
|
|
2. \"start_writing\" - User wants to write the full article
|
|
3. \"refine_content\" - User wants to improve existing content
|
|
4. \"add_section\" - User wants to add a new section to existing outline or article
|
|
5. \"continue_chat\" - User wants to continue discussing/exploring
|
|
6. \"clarify\" - User is asking questions or needs clarification
|
|
|
|
Consider:
|
|
- The user's explicit request
|
|
- Whether they have an outline already (has_plan: {$has_plan_str})
|
|
- Current mode (current_mode: {$current_mode})
|
|
|
|
User's message: \"{$last_message}\"
|
|
|
|
Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
|
|
|
|
// Call AI with clarity model for intent detection
|
|
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
|
"clarity",
|
|
);
|
|
$provider = $provider_result->provider;
|
|
$messages = [
|
|
[
|
|
"role" => "user",
|
|
"content" => $prompt,
|
|
],
|
|
];
|
|
|
|
$response = $provider->chat($messages, [], "intent_detection");
|
|
|
|
if (is_wp_error($response)) {
|
|
return $response;
|
|
}
|
|
|
|
// Track cost
|
|
$this->track_ai_cost(
|
|
$post_id,
|
|
$response["model"] ?? "",
|
|
"detect_intent",
|
|
$response["input_tokens"] ?? 0,
|
|
$response["output_tokens"] ?? 0,
|
|
$response["cost"] ?? 0,
|
|
$provider_result,
|
|
$session_id ?? "",
|
|
"success",
|
|
);
|
|
|
|
// Clean up response
|
|
$intent = trim(strtolower($response["content"] ?? "continue_chat"));
|
|
$intent = str_replace('"', "", $intent);
|
|
|
|
// Validate intent
|
|
$valid_intents = [
|
|
"create_outline",
|
|
"start_writing",
|
|
"refine_content",
|
|
"add_section",
|
|
"continue_chat",
|
|
"clarify",
|
|
];
|
|
if (!in_array($intent, $valid_intents, true)) {
|
|
$intent = "continue_chat";
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"intent" => $intent,
|
|
"cost" => $response["cost"] ?? 0,
|
|
"provider_metadata" => $this->build_provider_metadata(
|
|
$provider_result,
|
|
$response["model"] ?? "",
|
|
),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle get image recommendations request.
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_get_image_recommendations($request)
|
|
{
|
|
$post_id = $request->get_param("post_id");
|
|
|
|
if ($post_id > 0 && !$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to access this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
|
$images = $image_manager->get_image_recommendations($post_id);
|
|
|
|
// Block-level sync: ensure each unresolved image block has a stable
|
|
// agent id and a corresponding recommendation row.
|
|
if ($post_id > 0) {
|
|
$post = get_post($post_id);
|
|
if ($post instanceof WP_Post && !empty($post->post_content)) {
|
|
$post_config = $this->get_post_config($post_id);
|
|
if (!empty($post_config["include_images"])) {
|
|
$images = $this->sync_image_block_recommendations(
|
|
$post_id,
|
|
$post,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new WP_REST_Response(["images" => $images], 200);
|
|
}
|
|
|
|
/**
|
|
* Ensure unresolved image blocks are mapped 1:1 to recommendation rows.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param WP_Post $post Post object.
|
|
* @return array
|
|
*/
|
|
public function sync_image_block_recommendations($post_id, $post)
|
|
{
|
|
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
|
$post_content = (string) $post->post_content;
|
|
$blocks = parse_blocks($post_content);
|
|
$changed = false;
|
|
$slots = [];
|
|
$slot_index = 0;
|
|
$post_title = trim(wp_strip_all_tags((string) $post->post_title));
|
|
|
|
$walk = function (&$items, $heading_context = "") use (
|
|
&$walk,
|
|
&$changed,
|
|
&$slots,
|
|
&$slot_index,
|
|
$post_id,
|
|
$post_title,
|
|
) {
|
|
foreach ($items as &$block) {
|
|
$name = $block["blockName"] ?? "";
|
|
$attrs = $block["attrs"] ?? [];
|
|
|
|
if ("core/heading" === $name) {
|
|
$heading = "";
|
|
if (!empty($attrs["content"])) {
|
|
$heading = trim(
|
|
wp_strip_all_tags((string) $attrs["content"]),
|
|
);
|
|
} elseif (!empty($block["innerHTML"])) {
|
|
$heading = trim(
|
|
wp_strip_all_tags((string) $block["innerHTML"]),
|
|
);
|
|
}
|
|
if ("" !== $heading) {
|
|
$heading_context = $heading;
|
|
}
|
|
}
|
|
|
|
if ("core/image" === $name) {
|
|
$image_id = isset($attrs["id"]) ? (int) $attrs["id"] : 0;
|
|
if ($image_id <= 0) {
|
|
$slot_index++;
|
|
$agent_id = isset($attrs["data-agent-image-id"])
|
|
? trim((string) $attrs["data-agent-image-id"])
|
|
: "";
|
|
if ("" === $agent_id) {
|
|
$agent_id =
|
|
"img_" .
|
|
$post_id .
|
|
"_blk_" .
|
|
$slot_index .
|
|
"_" .
|
|
substr(
|
|
wp_hash(microtime(true) . wp_rand()),
|
|
0,
|
|
8,
|
|
);
|
|
$attrs["data-agent-image-id"] = $agent_id;
|
|
$class_name = isset($attrs["className"])
|
|
? (string) $attrs["className"]
|
|
: "";
|
|
if (
|
|
false === strpos($class_name, "wpaw-agent-img-")
|
|
) {
|
|
$attrs["className"] = trim(
|
|
$class_name .
|
|
" wpaw-agent-img-" .
|
|
$agent_id,
|
|
);
|
|
}
|
|
$block["attrs"] = $attrs;
|
|
$changed = true;
|
|
}
|
|
|
|
$slots[] = [
|
|
"agent_image_id" => $agent_id,
|
|
"section_title" =>
|
|
"" !== $heading_context
|
|
? $heading_context
|
|
: ("" !== $post_title
|
|
? $post_title
|
|
: "Article Section"),
|
|
"slot_index" => $slot_index,
|
|
];
|
|
}
|
|
}
|
|
|
|
if (
|
|
!empty($block["innerBlocks"]) &&
|
|
is_array($block["innerBlocks"])
|
|
) {
|
|
$walk($block["innerBlocks"], $heading_context);
|
|
}
|
|
}
|
|
unset($block);
|
|
};
|
|
|
|
$walk($blocks, "");
|
|
|
|
if ($changed) {
|
|
$serialized = serialize_blocks($blocks);
|
|
if ($serialized !== $post_content) {
|
|
wp_update_post([
|
|
"ID" => $post_id,
|
|
"post_content" => $serialized,
|
|
]);
|
|
}
|
|
}
|
|
|
|
$current_images = $image_manager->get_image_recommendations($post_id);
|
|
$by_agent_id = [];
|
|
$existing_rows = [];
|
|
if (is_array($current_images)) {
|
|
foreach ($current_images as $row) {
|
|
$key = isset($row["agent_image_id"])
|
|
? (string) $row["agent_image_id"]
|
|
: "";
|
|
if ("" !== $key) {
|
|
$by_agent_id[$key] = true;
|
|
}
|
|
$existing_rows[] = $row;
|
|
}
|
|
}
|
|
|
|
$slot_agent_ids = [];
|
|
foreach ($slots as $slot) {
|
|
$slot_agent_ids[$slot["agent_image_id"]] = true;
|
|
}
|
|
|
|
$orphan_rows = [];
|
|
foreach ($existing_rows as $row) {
|
|
$key = isset($row["agent_image_id"])
|
|
? (string) $row["agent_image_id"]
|
|
: "";
|
|
if ("" !== $key && !isset($slot_agent_ids[$key])) {
|
|
$orphan_rows[] = $row;
|
|
}
|
|
}
|
|
|
|
$focus_variants = [
|
|
"establishing scene",
|
|
"close-up detail",
|
|
"human activity and impact",
|
|
"before-and-after comparison",
|
|
"infographic-like composition",
|
|
];
|
|
|
|
foreach ($slots as $slot) {
|
|
$agent_id = $slot["agent_image_id"];
|
|
if (isset($by_agent_id[$agent_id])) {
|
|
continue;
|
|
}
|
|
|
|
$focus =
|
|
$focus_variants[
|
|
((int) $slot["slot_index"] - 1) % count($focus_variants)
|
|
];
|
|
$section_title = $slot["section_title"];
|
|
$prompt =
|
|
'Contextual image for section "' .
|
|
$section_title .
|
|
'" with focus on ' .
|
|
$focus .
|
|
". Realistic editorial style, informative composition, natural lighting, high detail.";
|
|
|
|
if (!empty($orphan_rows)) {
|
|
$orphan = array_shift($orphan_rows);
|
|
if (isset($orphan["id"])) {
|
|
global $wpdb;
|
|
$table = $wpdb->prefix . "wpaw_images";
|
|
$wpdb->update(
|
|
$table,
|
|
[
|
|
"agent_image_id" => $agent_id,
|
|
"placement" => "slot_" . (int) $slot["slot_index"],
|
|
"section_title" => $section_title,
|
|
"prompt_initial" => $prompt,
|
|
"alt_text_initial" =>
|
|
"Gambar untuk bagian: " . $section_title,
|
|
],
|
|
[
|
|
"id" => (int) $orphan["id"],
|
|
"post_id" => (int) $post_id,
|
|
],
|
|
["%s", "%s", "%s", "%s", "%s"],
|
|
["%d", "%d"],
|
|
);
|
|
$by_agent_id[$agent_id] = true;
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$image_manager->save_image_recommendation(
|
|
$post_id,
|
|
$agent_id,
|
|
"slot_" . (int) $slot["slot_index"],
|
|
$section_title,
|
|
$prompt,
|
|
"Gambar untuk bagian: " . $section_title,
|
|
);
|
|
}
|
|
|
|
$result = $image_manager->get_image_recommendations($post_id);
|
|
return is_array($result) ? $result : [];
|
|
}
|
|
|
|
/**
|
|
* Seed deterministic image recommendations from post content.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param string $post_title Post title.
|
|
* @param string $post_content Post content.
|
|
* @return bool True when at least one recommendation is saved.
|
|
*/
|
|
private function seed_basic_image_recommendations(
|
|
$post_id,
|
|
$post_title,
|
|
$post_content,
|
|
) {
|
|
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
|
$existing = $image_manager->get_image_recommendations($post_id);
|
|
if (is_array($existing) && !empty($existing)) {
|
|
return true;
|
|
}
|
|
|
|
$max_images = 3;
|
|
$title = trim(wp_strip_all_tags((string) $post_title));
|
|
$seeded = 0;
|
|
|
|
if ("" !== $title) {
|
|
$agent_image_id = "img_" . $post_id . "_" . time() . "_hero";
|
|
$image_manager->save_image_recommendation(
|
|
$post_id,
|
|
$agent_image_id,
|
|
"hero",
|
|
$title,
|
|
"Editorial hero image illustrating: " .
|
|
$title .
|
|
". Documentary style, natural lighting, high detail.",
|
|
"Ilustrasi utama artikel: " . $title,
|
|
);
|
|
$seeded++;
|
|
}
|
|
|
|
$headings = [];
|
|
if (
|
|
preg_match_all(
|
|
"/<h[2-4][^>]*>(.*?)<\/h[2-4]>/i",
|
|
$post_content,
|
|
$matches,
|
|
)
|
|
) {
|
|
foreach ($matches[1] as $heading) {
|
|
$clean = trim(wp_strip_all_tags($heading));
|
|
if ("" !== $clean) {
|
|
$headings[] = $clean;
|
|
}
|
|
if (count($headings) >= $max_images - 1) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($headings as $index => $heading) {
|
|
$agent_image_id =
|
|
"img_" . $post_id . "_" . time() . "_sec_" . ($index + 1);
|
|
$image_manager->save_image_recommendation(
|
|
$post_id,
|
|
$agent_image_id,
|
|
"section_" . ($index + 1),
|
|
$heading,
|
|
'Contextual supporting image for section "' .
|
|
$heading .
|
|
'". Realistic scene, informative composition, editorial quality.',
|
|
"Gambar pendukung untuk bagian: " . $heading,
|
|
);
|
|
$seeded++;
|
|
}
|
|
|
|
return $seeded > 0;
|
|
}
|
|
|
|
/**
|
|
* Ensure recommendations exist for every unresolved image block.
|
|
*
|
|
* @param int $post_id Post ID.
|
|
* @param string $post_content Post content.
|
|
* @param string $post_title Post title.
|
|
* @return void
|
|
*/
|
|
private function ensure_recommendations_for_image_blocks(
|
|
$post_id,
|
|
$post_content,
|
|
$post_title,
|
|
) {
|
|
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
|
$current_images = $image_manager->get_image_recommendations($post_id);
|
|
$current_count = is_array($current_images) ? count($current_images) : 0;
|
|
|
|
$image_slots = $this->extract_unresolved_image_slots($post_content);
|
|
$target_count = count($image_slots);
|
|
if ($target_count <= $current_count) {
|
|
return;
|
|
}
|
|
|
|
$fallback_title = trim(wp_strip_all_tags((string) $post_title));
|
|
for ($i = $current_count; $i < $target_count; $i++) {
|
|
$slot_title = isset($image_slots[$i]["section_title"])
|
|
? $image_slots[$i]["section_title"]
|
|
: "";
|
|
$section_title =
|
|
"" !== $slot_title
|
|
? $slot_title
|
|
: ("" !== $fallback_title
|
|
? $fallback_title
|
|
: "Article Section");
|
|
$agent_image_id =
|
|
"img_" . $post_id . "_" . time() . "_slot_" . ($i + 1);
|
|
$focus_variants = [
|
|
"establishing scene",
|
|
"close-up detail",
|
|
"human activity and impact",
|
|
"before-and-after comparison",
|
|
"infographic-like composition",
|
|
];
|
|
$focus = $focus_variants[$i % count($focus_variants)];
|
|
$prompt =
|
|
'Contextual image for section "' .
|
|
$section_title .
|
|
'" with focus on ' .
|
|
$focus .
|
|
". Realistic editorial style, informative composition, natural lighting, high detail.";
|
|
|
|
$image_manager->save_image_recommendation(
|
|
$post_id,
|
|
$agent_image_id,
|
|
"slot_" . ($i + 1),
|
|
$section_title,
|
|
$prompt,
|
|
"Gambar untuk bagian: " . $section_title,
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Extract unresolved image slots with nearest heading context.
|
|
*
|
|
* @param string $post_content Post content.
|
|
* @return array
|
|
*/
|
|
/**
|
|
* Extract unresolved image slots.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::extract_unresolved_image_slots() instead.
|
|
* @param string $post_content Post content.
|
|
* @return array
|
|
*/
|
|
private function extract_unresolved_image_slots($post_content)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::extract_unresolved_image_slots(
|
|
$post_content,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle generate image request.
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_generate_image($request)
|
|
{
|
|
// Check rate limit.
|
|
$rate_limit = WPAW_Rate_Limiter::check("generate_image");
|
|
if (is_wp_error($rate_limit)) {
|
|
return $rate_limit;
|
|
}
|
|
|
|
$post_id = $request->get_param("post_id");
|
|
$agent_image_id = $request->get_param("agent_image_id");
|
|
$prompt = $request->get_param("prompt");
|
|
$variant_count = $request->get_param("variant_count") ?? 2;
|
|
|
|
if ($post_id > 0 && !$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
|
$variants = $image_manager->generate_image_variants(
|
|
$post_id,
|
|
$agent_image_id,
|
|
$prompt,
|
|
$variant_count,
|
|
);
|
|
|
|
if (is_wp_error($variants)) {
|
|
return $variants;
|
|
}
|
|
|
|
return new WP_REST_Response(["variants" => $variants], 200);
|
|
}
|
|
|
|
/**
|
|
* Handle commit image request.
|
|
*
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_commit_image($request)
|
|
{
|
|
$post_id = $request->get_param("post_id");
|
|
$agent_image_id = $request->get_param("agent_image_id");
|
|
$variant_id = $request->get_param("variant_id");
|
|
$alt_text = $request->get_param("alt");
|
|
|
|
if ($post_id > 0 && !$this->check_post_permission($post_id)) {
|
|
return new WP_Error(
|
|
"forbidden",
|
|
__(
|
|
"You do not have permission to edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
|
$result = $image_manager->commit_image_variant(
|
|
$post_id,
|
|
$agent_image_id,
|
|
$variant_id,
|
|
$alt_text,
|
|
);
|
|
|
|
if (is_wp_error($result)) {
|
|
return $result;
|
|
}
|
|
|
|
return new WP_REST_Response($result, 200);
|
|
}
|
|
|
|
/**
|
|
* Parse refined blocks from AI response.
|
|
*
|
|
* @param string $content AI response content.
|
|
* @param int $expected_count Expected number of blocks.
|
|
* @return array Array of block contents.
|
|
*/
|
|
/**
|
|
* Parse refined blocks.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Sidebar_Helpers::parse_refined_blocks() instead.
|
|
* @param string $content Content.
|
|
* @param int $expected_count Expected count.
|
|
* @return array
|
|
*/
|
|
public function parse_refined_blocks($content, $expected_count = 0)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-sidebar-helpers.php";
|
|
return WP_Agentic_Writer_Sidebar_Helpers::parse_refined_blocks(
|
|
$content,
|
|
$expected_count,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle generate title request.
|
|
*
|
|
* Uses WordPress 7.0 AI Client when available, falls back to legacy.
|
|
*
|
|
* @since 0.1.4
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_generate_title($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$content = sanitize_textarea_field($params["content"] ?? "");
|
|
|
|
if (empty($content)) {
|
|
return new WP_Error(
|
|
"missing_content",
|
|
__(
|
|
"Content is required for title generation.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
$options = [
|
|
"post_id" => isset($params["post_id"])
|
|
? (int) $params["post_id"]
|
|
: 0,
|
|
];
|
|
|
|
$client = WPAW_WP_AI_Client::get_instance();
|
|
$result = $client->generate_title($content, $options);
|
|
|
|
if (is_wp_error($result)) {
|
|
return $result;
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"title" => $result,
|
|
"source" => $client->get_ai_mode(),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle generate excerpt request.
|
|
*
|
|
* Uses WordPress 7.0 AI Client when available, falls back to legacy.
|
|
*
|
|
* @since 0.1.4
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_generate_excerpt($request)
|
|
{
|
|
$params = $request->get_json_params();
|
|
$content = sanitize_textarea_field($params["content"] ?? "");
|
|
|
|
if (empty($content)) {
|
|
return new WP_Error(
|
|
"missing_content",
|
|
__(
|
|
"Content is required for excerpt generation.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
$options = [
|
|
"post_id" => isset($params["post_id"])
|
|
? (int) $params["post_id"]
|
|
: 0,
|
|
];
|
|
|
|
$client = WPAW_WP_AI_Client::get_instance();
|
|
$result = $client->generate_excerpt($content, $options);
|
|
|
|
if (is_wp_error($result)) {
|
|
return $result;
|
|
}
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"excerpt" => $result,
|
|
"source" => $client->get_ai_mode(),
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Handle get AI capabilities request.
|
|
*
|
|
* Returns the current AI capabilities based on available providers.
|
|
*
|
|
* @since 0.1.4
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response
|
|
*/
|
|
public function handle_get_ai_capabilities($request)
|
|
{
|
|
$client = WPAW_WP_AI_Client::get_instance();
|
|
$capabilities = $client->get_capabilities();
|
|
|
|
return new WP_REST_Response($capabilities, 200);
|
|
}
|
|
|
|
/**
|
|
* Handle search request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_search($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Chat($this);
|
|
return $ctrl->handle_search($request);
|
|
}
|
|
|
|
/**
|
|
* Handle fetch content request for research.
|
|
*
|
|
* Fetches and extracts content from a URL for AI context.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_fetch_content($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Chat($this);
|
|
return $ctrl->handle_fetch_content($request);
|
|
}
|
|
|
|
/**
|
|
* Handle research summary request.
|
|
*
|
|
* Performs multiple searches and generates a research summary.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Chat instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_research_summary($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-chat.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Chat($this);
|
|
return $ctrl->handle_research_summary($request);
|
|
}
|
|
|
|
/**
|
|
* Handle get conversations request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_get_conversations($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-conversation.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Conversation($this);
|
|
return $ctrl->handle_get_conversations($request);
|
|
}
|
|
|
|
/**
|
|
* Handle create conversation request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_create_conversation($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-conversation.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Conversation($this);
|
|
return $ctrl->handle_create_conversation($request);
|
|
}
|
|
|
|
/**
|
|
* Handle get single conversation request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_get_conversation($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-conversation.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Conversation($this);
|
|
return $ctrl->handle_get_conversation($request);
|
|
}
|
|
|
|
/**
|
|
* Restore rich plan UI payloads for sessions that only stored a text summary.
|
|
*
|
|
* @since 0.2.2
|
|
* @param array $session Conversation session.
|
|
* @return array
|
|
*/
|
|
private function hydrate_session_plan_messages($session)
|
|
{
|
|
if (!is_array($session)) {
|
|
return $session;
|
|
}
|
|
|
|
$post_id = isset($session["post_id"]) ? (int) $session["post_id"] : 0;
|
|
if (
|
|
$post_id <= 0 ||
|
|
empty($session["messages"]) ||
|
|
!is_array($session["messages"])
|
|
) {
|
|
return $session;
|
|
}
|
|
|
|
foreach ($session["messages"] as $message) {
|
|
if (
|
|
isset($message["type"]) &&
|
|
"plan" === $message["type"] &&
|
|
!empty($message["plan"])
|
|
) {
|
|
return $session;
|
|
}
|
|
}
|
|
|
|
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
|
if (!is_array($plan)) {
|
|
return $session;
|
|
}
|
|
|
|
foreach ($session["messages"] as $index => $message) {
|
|
$content = isset($message["content"])
|
|
? (string) $message["content"]
|
|
: "";
|
|
$role = isset($message["role"]) ? (string) $message["role"] : "";
|
|
if (
|
|
"assistant" !== $role ||
|
|
false === strpos($content, "Outline ready.")
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
$session["messages"][$index]["type"] = "plan";
|
|
$session["messages"][$index]["plan"] = $plan;
|
|
break;
|
|
}
|
|
|
|
return $session;
|
|
}
|
|
|
|
/**
|
|
* Handle update conversation request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_update_conversation($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-conversation.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Conversation($this);
|
|
return $ctrl->handle_update_conversation($request);
|
|
}
|
|
|
|
/**
|
|
* Handle delete conversation request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_delete_conversation($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-conversation.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Conversation($this);
|
|
return $ctrl->handle_delete_conversation($request);
|
|
}
|
|
|
|
/**
|
|
* Handle update conversation messages request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_update_conversation_messages($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-conversation.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Conversation($this);
|
|
return $ctrl->handle_update_conversation_messages($request);
|
|
}
|
|
|
|
/**
|
|
* Handle link conversation to post request.
|
|
*
|
|
* @since 0.1.4
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Conversation instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_link_conversation_to_post($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR .
|
|
"includes/class-controller-conversation.php";
|
|
$ctrl = new WP_Agentic_Writer_Controller_Conversation($this);
|
|
return $ctrl->handle_link_conversation_to_post($request);
|
|
}
|
|
|
|
/**
|
|
* Handle migrate chat history request.
|
|
*
|
|
* Migrates legacy _wpaw_chat_history from post meta to session table.
|
|
*
|
|
* @since 0.1.4
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_migrate_chat_history($request)
|
|
{
|
|
$post_id = (int) $request->get_param("post_id");
|
|
|
|
if ($post_id <= 0) {
|
|
return new WP_Error(
|
|
"invalid_post",
|
|
__("Valid post ID is required.", "wp-agentic-writer"),
|
|
["status" => 400],
|
|
);
|
|
}
|
|
|
|
// Verify post exists and user can edit
|
|
if (!current_user_can("edit_post", $post_id)) {
|
|
return new WP_Error(
|
|
"permission_denied",
|
|
__(
|
|
"You do not have permission to edit this post.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 403],
|
|
);
|
|
}
|
|
|
|
// Use Context Service for migration
|
|
$context_service = WP_Agentic_Writer_Context_Service::get_instance();
|
|
$result = $context_service->migrate_legacy_chat_history($post_id);
|
|
|
|
if (!$result) {
|
|
return new WP_Error(
|
|
"migration_failed",
|
|
__("Failed to migrate chat history.", "wp-agentic-writer"),
|
|
["status" => 500],
|
|
);
|
|
}
|
|
|
|
// Return migration status
|
|
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
|
$sessions = $manager->get_sessions_for_post($post_id);
|
|
|
|
return new WP_REST_Response(
|
|
[
|
|
"migrated" => true,
|
|
"post_id" => $post_id,
|
|
"sessions_count" => count($sessions),
|
|
"message" =>
|
|
"Legacy chat history has been migrated to session table.",
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Auto-save post and link conversation when writing execution begins.
|
|
*
|
|
* @since 0.1.4
|
|
* @param string $session_id Session ID.
|
|
* @param int $post_id Current post ID (can be 0).
|
|
* @return int New post ID if saved, or original if not needed.
|
|
*/
|
|
public function ensure_conversation_linked_to_post(
|
|
$session_id,
|
|
$post_id = 0,
|
|
) {
|
|
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
|
|
|
// Already linked
|
|
if ($post_id > 0) {
|
|
return $post_id;
|
|
}
|
|
|
|
// Check if editor has content
|
|
if (!$manager->post_has_content(get_the_ID())) {
|
|
// No content yet, keep as post_id = 0
|
|
return 0;
|
|
}
|
|
|
|
// Get current post (auto-save with placeholder title)
|
|
$current_post_id = get_the_ID();
|
|
if ($current_post_id && $current_post_id > 0) {
|
|
// Update post with placeholder title if needed
|
|
$post = get_post($current_post_id);
|
|
if ($post && empty($post->post_title)) {
|
|
wp_update_post([
|
|
"ID" => $current_post_id,
|
|
"post_title" => "Draft - " . date("Y-m-d H:i"),
|
|
]);
|
|
}
|
|
|
|
// Link conversation to post
|
|
$manager->link_to_post($session_id, $current_post_id);
|
|
|
|
return $current_post_id;
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Get user preferences (per-user settings).
|
|
*
|
|
* @since 0.2.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_get_user_preferences($request)
|
|
{
|
|
$user_id = get_current_user_id();
|
|
|
|
// Return defaults if not logged in
|
|
if ($user_id === 0) {
|
|
return new WP_REST_Response(
|
|
[
|
|
"proactive_suggestions" => true,
|
|
"command_palette_enabled" => true,
|
|
"outline_panel_enabled" => true,
|
|
"auto_save_interval" => 30,
|
|
"preferred_model" => "",
|
|
"preferred_language" => "auto",
|
|
"theme" => "dark",
|
|
],
|
|
200,
|
|
);
|
|
}
|
|
|
|
$preferences = get_user_meta($user_id, "wpaw_user_preferences", true);
|
|
|
|
// Merge with defaults
|
|
$defaults = [
|
|
"proactive_suggestions" => true,
|
|
"command_palette_enabled" => true,
|
|
"outline_panel_enabled" => true,
|
|
"auto_save_interval" => 30,
|
|
"preferred_model" => "",
|
|
"preferred_language" => "auto",
|
|
"theme" => "dark",
|
|
];
|
|
|
|
$preferences = is_array($preferences)
|
|
? array_merge($defaults, $preferences)
|
|
: $defaults;
|
|
|
|
return new WP_REST_Response($preferences, 200);
|
|
}
|
|
|
|
/**
|
|
* Save user preferences (per-user settings).
|
|
*
|
|
* @since 0.2.0
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_save_user_preferences($request)
|
|
{
|
|
$user_id = get_current_user_id();
|
|
|
|
if ($user_id === 0) {
|
|
return new WP_Error(
|
|
"unauthorized",
|
|
__(
|
|
"You must be logged in to save preferences.",
|
|
"wp-agentic-writer",
|
|
),
|
|
["status" => 401],
|
|
);
|
|
}
|
|
|
|
$preferences = $request->get_json_params();
|
|
|
|
// Validate and sanitize
|
|
$sanitized = [
|
|
"proactive_suggestions" => !empty(
|
|
$preferences["proactive_suggestions"]
|
|
),
|
|
"command_palette_enabled" => !empty(
|
|
$preferences["command_palette_enabled"]
|
|
),
|
|
"outline_panel_enabled" => !empty(
|
|
$preferences["outline_panel_enabled"]
|
|
),
|
|
"auto_save_interval" => max(
|
|
5,
|
|
min(300, (int) ($preferences["auto_save_interval"] ?? 30)),
|
|
),
|
|
"preferred_model" => sanitize_text_field(
|
|
$preferences["preferred_model"] ?? "",
|
|
),
|
|
"preferred_language" => sanitize_text_field(
|
|
$preferences["preferred_language"] ?? "auto",
|
|
),
|
|
"theme" => in_array(
|
|
$preferences["theme"] ?? "dark",
|
|
["dark", "light"],
|
|
true,
|
|
)
|
|
? $preferences["theme"]
|
|
: "dark",
|
|
];
|
|
|
|
update_user_meta($user_id, "wpaw_user_preferences", $sanitized);
|
|
|
|
// MEMANTO: Store user preferences to user agent.
|
|
do_action("wpaw_memanto_config_saved", 0, $sanitized);
|
|
|
|
return new WP_REST_Response($sanitized, 200);
|
|
}
|
|
|
|
/**
|
|
* Handle MEMANTO status check.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response Status response.
|
|
*/
|
|
public function handle_memanto_status($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Memanto($this);
|
|
return $controller->handle_memanto_status($request);
|
|
}
|
|
|
|
/**
|
|
* Handle MEMANTO recall request.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error Response.
|
|
*/
|
|
public function handle_memanto_recall($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Memanto($this);
|
|
return $controller->handle_memanto_recall($request);
|
|
}
|
|
|
|
/**
|
|
* Handle MEMANTO session restore.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead.
|
|
* @param WP_REST_Request $request REST request with post_id param.
|
|
* @return WP_REST_Response|WP_Error Restore payload.
|
|
*/
|
|
public function handle_memanto_restore($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Memanto($this);
|
|
return $controller->handle_memanto_restore($request);
|
|
}
|
|
|
|
/**
|
|
* Handle MEMANTO user preferences recall.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Memanto instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response Preference payload.
|
|
*/
|
|
public function handle_memanto_preferences($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-memanto.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Memanto($this);
|
|
return $controller->handle_memanto_preferences($request);
|
|
}
|
|
|
|
/**
|
|
* Handle session lock acquire/refresh (heartbeat).
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Session instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_session_lock($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-session.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Session($this);
|
|
return $controller->handle_session_lock($request);
|
|
}
|
|
|
|
/**
|
|
* Handle session lock release.
|
|
*
|
|
* @deprecated 0.3.0 Use WP_Agentic_Writer_Controller_Session instead.
|
|
* @param WP_REST_Request $request REST request.
|
|
* @return WP_REST_Response|WP_Error
|
|
*/
|
|
public function handle_session_unlock($request)
|
|
{
|
|
require_once WPAW_PLUGIN_DIR . "includes/class-controller-session.php";
|
|
$controller = new WP_Agentic_Writer_Controller_Session($this);
|
|
return $controller->handle_session_unlock($request);
|
|
}
|
|
}
|