Files
wp-agentic-writer/includes/class-settings-v2.php
Dwindi Ramadhana d3f142222c feat: Add connection test caching and reasoning content support
Backend improvements:
- Add cache auto-clear on settings save (class-settings-v2.php)
  - Hooks updated_option action
  - Clears connection test transients when local backend settings change
- Add reasoning_content streaming support (class-local-backend-provider.php)
  - Handles thinking models like Claude extended thinking
  - Captures chunk['choices'][0]['delta']['reasoning_content']

Documentation:
- Add FRONTEND_AND_CHAT_FIX_SUMMARY.md with all fixes
- Add FRONTEND-REFACTOR-PHASE2.md with modularization plan

Note: Splitting effort deferred - will continue iterating on monolith
Next: Fix session history not appearing bug
2026-06-17 05:26:12 +07:00

2103 lines
72 KiB
PHP

<?php
/**
* Settings Page V2
*
* Refactored settings page with Agentic design tokens and separated view files.
*
* @package WP_Agentic_Writer
*/
if (!defined("ABSPATH")) {
exit();
}
/**
* Class WP_Agentic_Writer_Settings_V2
*
* @since 0.2.0
*/
class WP_Agentic_Writer_Settings_V2
{
/**
* Get singleton instance.
*
* @since 0.2.0
* @return WP_Agentic_Writer_Settings_V2
*/
public static function get_instance()
{
static $instance = null;
if (null === $instance) {
$instance = new self();
}
return $instance;
}
/**
* Constructor.
*
* @since 0.2.0
*/
private function __construct()
{
add_action("admin_menu", [$this, "add_settings_page"]);
add_action("admin_init", [$this, "register_settings"]);
add_action("admin_enqueue_scripts", [$this, "enqueue_scripts"]);
add_action("wp_ajax_wpaw_refresh_models", [
$this,
"ajax_refresh_models",
]);
add_action("wp_ajax_wpaw_get_cost_log_data", [
$this,
"ajax_get_cost_log_data",
]);
add_action("wp_ajax_wpaw_get_header_stats", [
$this,
"ajax_get_header_stats",
]);
add_action("wp_ajax_wpaw_test_api_connection", [
$this,
"ajax_test_api_connection",
]);
add_action("wp_ajax_wpaw_debug_models", [$this, "ajax_debug_models"]);
add_action("wp_ajax_wpaw_save_custom_model", [
$this,
"ajax_save_custom_model",
]);
add_action("wp_ajax_wpaw_delete_custom_model", [
$this,
"ajax_delete_custom_model",
]);
add_action("wp_ajax_wpaw_test_local_backend", [
$this,
"ajax_test_local_backend",
]);
add_action("wp_ajax_wpaw_test_memanto", [$this, "ajax_test_memanto"]);
// Clear connection test cache when local backend settings change
add_action(
"updated_option",
[$this, "clear_local_backend_cache_on_settings_change"],
10,
3,
);
}
/**
* Clear the local backend connection test cache when relevant settings change.
*
* This ensures that after a user updates their local backend URL, API key,
* or model settings, the cached "connection test" result is invalidated
* so the next chat request will re-test the connection.
*
* @since 0.2.0
* @param string $option Option name that was updated.
* @param mixed $old_value Old option value.
* @param mixed $new_value New option value.
*/
public function clear_local_backend_cache_on_settings_change(
$option,
$old_value,
$new_value,
) {
// Only handle our settings option
if ("wp_agentic_writer_settings" !== $option) {
return;
}
// Check if any local backend-related setting changed
$local_backend_keys = [
"local_backend_url",
"local_backend_key",
"local_backend_image_url",
"local_backend_image_key",
"local_backend_model",
"local_backend_models",
"local_backend_enabled",
"local_backend_image_enabled",
];
$old_value = is_array($old_value) ? $old_value : [];
$new_value = is_array($new_value) ? $new_value : [];
foreach ($local_backend_keys as $key) {
$old = $old_value[$key] ?? null;
$new = $new_value[$key] ?? null;
// Check if the value changed (handle both array and scalar comparisons)
if ($old !== $new) {
// Arrays need special comparison
if (is_array($old) && is_array($new)) {
if (serialize($old) !== serialize($new)) {
$this->do_clear_local_backend_cache();
return;
}
} else {
$this->do_clear_local_backend_cache();
return;
}
}
}
}
/**
* Actually clear the cache and log the action.
*
* @since 0.2.0
*/
private function do_clear_local_backend_cache()
{
if (!class_exists("WP_Agentic_Writer_Provider_Manager")) {
return;
}
$count = WP_Agentic_Writer_Provider_Manager::clear_connection_test_cache();
if (defined("WP_DEBUG") && WP_DEBUG) {
error_log(
"WPAW Settings: Local backend settings changed. Cleared " .
$count .
" connection test cache entries.",
);
}
}
/**
* Enqueue scripts for settings page.
*
* @since 0.2.0
* @param string $hook Current admin page hook.
*/
public function enqueue_scripts($hook)
{
if ("settings_page_wp-agentic-writer-settings" !== $hook) {
return;
}
$settings_css_path =
WP_AGENTIC_WRITER_DIR . "views/settings-v2/style.css";
wp_enqueue_style(
"wpaw-settings-v2-stitch",
WP_AGENTIC_WRITER_URL . "views/settings-v2/style.css",
[],
file_exists($settings_css_path)
? filemtime($settings_css_path)
: WP_AGENTIC_WRITER_VERSION,
);
wp_enqueue_style(
"wpaw-select2",
WP_AGENTIC_WRITER_URL . "assets/vendor/select2/select2.min.css",
[],
"4.1.0-rc.0",
);
wp_enqueue_script(
"wpaw-select2",
WP_AGENTIC_WRITER_URL . "assets/vendor/select2/select2.min.js",
["jquery"],
"4.1.0-rc.0",
true,
);
wp_enqueue_script(
"wp-agentic-writer-settings-v2",
WP_AGENTIC_WRITER_URL . "assets/js/settings-v2-stitch.js",
["jquery", "wpaw-select2"],
WP_AGENTIC_WRITER_VERSION,
true,
);
$settings = get_option("wp_agentic_writer_settings", []);
wp_localize_script("wp-agentic-writer-settings-v2", "wpawSettingsV2", [
"ajaxUrl" => admin_url("admin-ajax.php"),
"nonce" => wp_create_nonce("wpaw_settings"),
"models" => $this->get_models_for_select(),
"currentModels" => [
"planning" =>
$settings["planning_model"] ??
WPAW_Model_Registry::get_default_model("planning"),
"writing" =>
$settings["writing_model"] ??
($settings["execution_model"] ??
WPAW_Model_Registry::get_default_model("writing")),
"execution" =>
$settings["writing_model"] ??
($settings["execution_model"] ??
WPAW_Model_Registry::get_default_model("writing")),
"clarity" =>
$settings["clarity_model"] ??
WPAW_Model_Registry::get_default_model("clarity"),
"refinement" =>
$settings["refinement_model"] ??
WPAW_Model_Registry::get_default_model("refinement"),
"chat" =>
$settings["chat_model"] ??
WPAW_Model_Registry::get_default_model("chat"),
"image" =>
$settings["image_model"] ??
WPAW_Model_Registry::get_default_model("image"),
],
"presets" => $this->get_model_presets(),
"i18n" => [
"refreshing" => __("Refreshing...", "wp-agentic-writer"),
"refreshModels" => __("Refresh Models", "wp-agentic-writer"),
"saveSuccess" => __(
"Settings saved successfully!",
"wp-agentic-writer",
),
"saveError" => __(
"Error saving settings.",
"wp-agentic-writer",
),
"confirmReset" => __(
"Are you sure you want to reset all settings to defaults?",
"wp-agentic-writer",
),
"loading" => __("Loading...", "wp-agentic-writer"),
"noResults" => __("No models found", "wp-agentic-writer"),
"searchPlaceholder" => __(
"Search models...",
"wp-agentic-writer",
),
],
]);
}
/**
* Get curated model presets (centralized source).
*
* These are intentional product decisions for different budget tiers.
* Model IDs may differ from registry defaults to balance cost/quality.
*
* @since 0.2.0
* @return array Curated model presets.
*/
public function get_model_presets()
{
return [
"budget" => [
"chat" => "google/gemini-2.5-flash",
"clarity" => "google/gemini-2.5-flash",
"planning" => "google/gemini-2.5-flash",
"writing" => "mistralai/mistral-small-creative",
"refinement" => "google/gemini-2.5-flash",
"image" => "openai/gpt-4o",
],
"balanced" => [
"chat" => "google/gemini-2.5-flash",
"clarity" => "google/gemini-2.5-flash",
"planning" => "google/gemini-2.5-flash",
"writing" => "anthropic/claude-sonnet-4",
"refinement" => "anthropic/claude-sonnet-4",
"image" => "openai/gpt-4o",
],
"premium" => [
"chat" => "google/gemini-3-flash-preview",
"clarity" => "anthropic/claude-sonnet-4",
"planning" => "google/gemini-3-flash-preview",
"writing" => "openai/gpt-4.1",
"refinement" => "openai/gpt-4.1",
"image" => "openai/gpt-4o",
],
];
}
/**
* Get models for select dropdowns.
*
* @since 0.2.0
* @return array Models grouped by category.
*/
public function get_models_for_select()
{
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->get_cached_models();
if (is_wp_error($models)) {
return $this->get_fallback_models();
}
$transformed = $this->transform_models_for_js($models);
// Debug logging
if (defined("WP_DEBUG") && WP_DEBUG) {
$custom_models = get_option("wp_agentic_writer_custom_models", []);
error_log(
"WPAW get_models_for_select: custom_models in DB = " .
wp_json_encode($custom_models),
);
error_log(
"WPAW get_models_for_select: image models count = " .
count($transformed["image"]["all"] ?? []),
);
}
return $transformed;
}
/**
* Format model name from ID.
*
* @since 0.2.0
* @param string $model_id Model ID.
* @return string Formatted model name.
*/
private function format_model_name($model_id)
{
// Remove provider prefix
$parts = explode("/", $model_id);
$name = end($parts);
// Remove :free suffix
$name = preg_replace('/:free$/i', "", $name);
// Convert hyphens and underscores to spaces
$name = str_replace(["-", "_"], " ", $name);
// Capitalize words
$name = ucwords($name);
// Add provider prefix back
if (count($parts) > 1) {
$provider = ucfirst($parts[0]);
$name = $provider . ": " . $name;
}
return $name;
}
/**
* Get fallback models when API fails.
*
* @since 0.2.0
* @return array Fallback model structure.
*/
private function get_fallback_models()
{
return [
"planning" => [
"recommended" => [
[
"id" => WPAW_Model_Registry::get_default_model(
"planning",
),
"name" => WPAW_Model_Registry::get_model_display_name(
WPAW_Model_Registry::get_default_model("planning"),
),
],
],
"all" => [
[
"id" => WPAW_Model_Registry::get_default_model(
"planning",
),
"name" => WPAW_Model_Registry::get_model_display_name(
WPAW_Model_Registry::get_default_model("planning"),
),
],
],
],
"execution" => [
"recommended" => [
[
"id" => WPAW_Model_Registry::get_fallback_model(
"execution",
),
"name" => WPAW_Model_Registry::get_model_display_name(
WPAW_Model_Registry::get_fallback_model(
"execution",
),
),
],
],
"all" => [
[
"id" => WPAW_Model_Registry::get_fallback_model(
"execution",
),
"name" => WPAW_Model_Registry::get_model_display_name(
WPAW_Model_Registry::get_fallback_model(
"execution",
),
),
],
],
],
"image" => [
"recommended" => [
[
"id" => WPAW_Model_Registry::get_default_model("image"),
"name" => WPAW_Model_Registry::get_model_display_name(
WPAW_Model_Registry::get_default_model("image"),
),
],
],
"all" => [
[
"id" => WPAW_Model_Registry::get_default_model("image"),
"name" => WPAW_Model_Registry::get_model_display_name(
WPAW_Model_Registry::get_default_model("image"),
),
],
],
],
];
}
/**
* Transform models structure for JavaScript consumption.
*
* @since 0.2.0
* @param array $models Models from provider.
* @return array Transformed models.
*/
private function transform_models_for_js($models)
{
// Handle flat model list from OpenRouter
if (
!empty($models) &&
array_keys($models) === range(0, count($models) - 1)
) {
$settings = get_option("wp_agentic_writer_settings", []);
$planning_id =
$settings["planning_model"] ??
WPAW_Model_Registry::get_default_model("planning");
$execution_id =
$settings["execution_model"] ??
WPAW_Model_Registry::get_default_model("execution");
$image_id =
$settings["image_model"] ??
WPAW_Model_Registry::get_default_model("image");
$text_models = [];
$image_models = [];
// Categorize models using OpenRouter's output_modalities field
foreach ($models as $model) {
if (empty($model["id"])) {
continue;
}
$prompt_price = isset($model["pricing"]["prompt"])
? (float) $model["pricing"]["prompt"]
: 0;
$completion_price = isset($model["pricing"]["completion"])
? (float) $model["pricing"]["completion"]
: 0;
$image_price = isset($model["pricing"]["image"])
? (float) $model["pricing"]["image"]
: 0;
$model_data = [
"id" => $model["id"],
"name" => $model["name"] ?? $model["id"],
"is_free" =>
$prompt_price <= 0.0 &&
$completion_price <= 0.0 &&
$image_price <= 0.0,
"pricing" => [
"prompt" => $prompt_price,
"completion" => $completion_price,
"image" => $image_price,
],
];
// Use OpenRouter's output_modalities to categorize - trust OpenRouter's classification
$output_modalities =
$model["architecture"]["output_modalities"] ?? [];
// Image generation models have 'image' in output_modalities
if (in_array("image", $output_modalities, true)) {
$image_models[] = $model_data;
}
// Text models have 'text' in output_modalities (most models)
if (in_array("text", $output_modalities, true)) {
$text_models[] = $model_data;
}
}
$chat_id =
$settings["chat_model"] ??
WPAW_Model_Registry::get_default_model("chat");
$clarity_id =
$settings["clarity_model"] ??
WPAW_Model_Registry::get_default_model("clarity");
$refinement_id =
$settings["refinement_model"] ??
WPAW_Model_Registry::get_default_model("refinement");
$writing_id =
$settings["writing_model"] ??
($settings["execution_model"] ??
WPAW_Model_Registry::get_default_model("writing"));
// Add currently selected models to text_models if not already present
$current_model_ids = [
$planning_id,
$execution_id,
$chat_id,
$clarity_id,
$refinement_id,
$writing_id,
];
foreach ($current_model_ids as $model_id) {
$found = false;
foreach ($text_models as $tm) {
if ($tm["id"] === $model_id) {
$found = true;
break;
}
}
if (!$found && !empty($model_id)) {
$text_models[] = [
"id" => $model_id,
"name" => $this->format_model_name($model_id),
"is_free" => false,
"pricing" => [
"prompt" => 0,
"completion" => 0,
"image" => 0,
],
];
}
}
// Add currently selected image model to image_models if not already present
if (!empty($image_id)) {
$found = false;
foreach ($image_models as $im) {
if ($im["id"] === $image_id) {
$found = true;
break;
}
}
if (!$found) {
$image_models[] = [
"id" => $image_id,
"name" => $this->format_model_name($image_id),
"is_free" => false,
"pricing" => [
"prompt" => 0,
"completion" => 0,
"image" => 0,
],
];
}
}
// Add user's custom models (not listed in API but callable by ID)
$custom_models = get_option("wp_agentic_writer_custom_models", []);
foreach ($custom_models as $custom) {
if (empty($custom["id"])) {
continue;
}
$custom_model_data = [
"id" => $custom["id"],
"name" => !empty($custom["name"])
? $custom["name"]
: $this->format_model_name($custom["id"]),
"is_free" => false,
"is_custom" => true,
"pricing" => [
"prompt" => 0,
"completion" => 0,
"image" => 0,
],
];
$type = $custom["type"] ?? "text";
if ("image" === $type) {
$image_models[] = $custom_model_data;
} else {
$text_models[] = $custom_model_data;
}
}
// Now create find_model closure after all models are added
$find_model = function ($model_id) use (
$text_models,
$image_models,
) {
foreach (array_merge($text_models, $image_models) as $model) {
if ($model["id"] === $model_id) {
return $model;
}
}
// If model not found, create a fallback entry
if (!empty($model_id)) {
return [
"id" => $model_id,
"name" => $this->format_model_name($model_id),
"is_free" => false,
"pricing" => [
"prompt" => 0,
"completion" => 0,
"image" => 0,
],
];
}
return null;
};
return [
"planning" => [
"recommended" => array_filter([$find_model($planning_id)]),
"all" => $text_models,
],
"execution" => [
"recommended" => array_filter([$find_model($execution_id)]),
"all" => $text_models,
],
"chat" => [
"recommended" => array_filter([$find_model($chat_id)]),
"all" => $text_models,
],
"image" => [
"recommended" => array_filter([$find_model($image_id)]),
"all" => $image_models,
],
];
}
$transformed = [];
foreach ($models as $type => $categories) {
if (!isset($transformed[$type])) {
$transformed[$type] = [
"recommended" => [],
"all" => [],
];
}
// Combine free and paid into 'all' array
$all_models = array_merge(
$categories["free"] ?? [],
$categories["paid"] ?? [],
);
// Remove duplicates
$recommended_ids = [];
foreach ($categories["recommended"] ?? [] as $model) {
$transformed[$type]["recommended"][] = $model;
$recommended_ids[$model["id"]] = true;
}
// Add all models, avoiding duplicates with recommended
foreach ($all_models as $model) {
if (!isset($recommended_ids[$model["id"]])) {
$transformed[$type]["all"][] = $model;
}
}
}
return $transformed;
}
/**
* AJAX handler for refreshing models.
*
* @since 0.2.0
*/
public function ajax_refresh_models()
{
if (!check_ajax_referer("wpaw_settings", "nonce", false)) {
wp_send_json_error(["message" => "Invalid nonce"]);
return;
}
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Permission denied"]);
}
$settings = get_option("wp_agentic_writer_settings", []);
$posted_api_key = isset($_POST["api_key"])
? trim(sanitize_text_field(wp_unslash($_POST["api_key"])))
: "";
$api_key = !empty($posted_api_key)
? $posted_api_key
: $settings["openrouter_api_key"] ?? "";
$provider = !empty($posted_api_key)
? WP_Agentic_Writer_OpenRouter_Provider::for_api_key($api_key)
: WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->fetch_and_cache_models(true);
if (is_wp_error($models)) {
wp_send_json_error(["message" => $models->get_error_message()]);
}
$transformed = $this->transform_models_for_js($models);
wp_send_json_success([
"models" => $transformed,
"message" => __(
"Models refreshed successfully!",
"wp-agentic-writer",
),
]);
}
/**
* AJAX handler for saving a custom model.
*
* @since 0.2.0
*/
public function ajax_save_custom_model()
{
if (!check_ajax_referer("wpaw_settings", "nonce", false)) {
wp_send_json_error(["message" => "Invalid nonce"]);
return;
}
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Permission denied"]);
return;
}
$model_id = isset($_POST["model_id"])
? sanitize_text_field($_POST["model_id"])
: "";
$model_name = isset($_POST["model_name"])
? sanitize_text_field($_POST["model_name"])
: "";
$model_type = isset($_POST["model_type"])
? sanitize_text_field($_POST["model_type"])
: "text";
if (empty($model_id)) {
wp_send_json_error(["message" => "Model ID is required"]);
return;
}
// Use separate option for custom models
$custom_models = get_option("wp_agentic_writer_custom_models", []);
// Check if model already exists, update it
$found = false;
foreach ($custom_models as $index => $cm) {
if ($cm["id"] === $model_id) {
$custom_models[$index] = [
"id" => $model_id,
"name" => $model_name,
"type" => $model_type,
];
$found = true;
break;
}
}
// Add new model if not found
if (!$found) {
$custom_models[] = [
"id" => $model_id,
"name" => $model_name,
"type" => $model_type,
];
}
$saved = update_option(
"wp_agentic_writer_custom_models",
array_values($custom_models),
);
// Get fresh combined models for Select2
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->get_cached_models();
if (is_wp_error($models)) {
$models = [];
}
$transformed = $this->transform_models_for_js($models);
wp_send_json_success([
"message" => __("Custom model saved!", "wp-agentic-writer"),
"models" => $transformed,
]);
}
/**
* AJAX handler for deleting a custom model.
*
* @since 0.2.0
*/
public function ajax_delete_custom_model()
{
if (!check_ajax_referer("wpaw_settings", "nonce", false)) {
wp_send_json_error(["message" => "Invalid nonce"]);
return;
}
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Permission denied"]);
return;
}
$model_id = isset($_POST["model_id"])
? sanitize_text_field($_POST["model_id"])
: "";
if (empty($model_id)) {
wp_send_json_error(["message" => "Model ID is required"]);
return;
}
// Use separate option for custom models
$custom_models = get_option("wp_agentic_writer_custom_models", []);
// Remove the model
$custom_models = array_filter($custom_models, function ($cm) use (
$model_id,
) {
return $cm["id"] !== $model_id;
});
update_option(
"wp_agentic_writer_custom_models",
array_values($custom_models),
);
// Get fresh combined models for Select2
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->get_cached_models();
if (is_wp_error($models)) {
$models = [];
}
$transformed = $this->transform_models_for_js($models);
wp_send_json_success([
"message" => __("Custom model deleted!", "wp-agentic-writer"),
"models" => $transformed,
]);
}
/**
* AJAX handler for getting cost log data (server-side pagination).
*
* @since 0.2.0
*/
public function ajax_get_cost_log_data()
{
if (!check_ajax_referer("wpaw_settings", "nonce", false)) {
wp_send_json_error(["message" => "Invalid nonce"]);
return;
}
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Permission denied"]);
return;
}
global $wpdb;
$table_name = $wpdb->prefix . "wpaw_cost_tracking";
// Check if table exists
$table_exists =
$wpdb->get_var("SHOW TABLES LIKE '{$table_name}'") === $table_name;
if (!$table_exists) {
wp_send_json_success([
"records" => [],
"total_items" => 0,
"total_pages" => 0,
"current_page" => 1,
"per_page" => 25,
"stats" => [
"all_time" => "0.0000",
"monthly" => "0.0000",
"today" => "0.0000",
"avg_per_post" => "0.0000",
"action_summary" => [],
],
"filters" => [
"models" => [],
"types" => [],
],
]);
return;
}
// Get parameters
$page = isset($_POST["page"]) ? max(1, intval($_POST["page"])) : 1;
$per_page = isset($_POST["per_page"])
? min(100, max(10, intval($_POST["per_page"])))
: 25;
$offset = ($page - 1) * $per_page;
// Filters
$filter_post = isset($_POST["filter_post"])
? intval($_POST["filter_post"])
: 0;
$filter_model = isset($_POST["filter_model"])
? sanitize_text_field($_POST["filter_model"])
: "";
$filter_type = isset($_POST["filter_type"])
? sanitize_text_field($_POST["filter_type"])
: "";
$filter_date_from = isset($_POST["filter_date_from"])
? sanitize_text_field($_POST["filter_date_from"])
: "";
$filter_date_to = isset($_POST["filter_date_to"])
? sanitize_text_field($_POST["filter_date_to"])
: "";
// Build WHERE clause (OpenRouter-only for this OpenRouter cost log screen).
$where = ["provider = 'openrouter'"];
if ($filter_post > 0) {
$where[] = $wpdb->prepare("post_id = %d", $filter_post);
}
if (!empty($filter_model)) {
$where[] = $wpdb->prepare("model = %s", $filter_model);
}
if (!empty($filter_type)) {
$where[] = $wpdb->prepare("action = %s", $filter_type);
}
if (!empty($filter_date_from)) {
$where[] = $wpdb->prepare(
"DATE(created_at) >= %s",
$filter_date_from,
);
}
if (!empty($filter_date_to)) {
$where[] = $wpdb->prepare(
"DATE(created_at) <= %s",
$filter_date_to,
);
}
$where_clause = implode(" AND ", $where);
// Get total count of distinct posts
$total_items = $wpdb->get_var(
"SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE {$where_clause}",
);
$total_pages = ceil($total_items / $per_page);
// Optimized: Get grouped records with aggregation in SQL.
// This pushes grouping and ordering to the database instead of PHP.
$grouped_records_sql = $wpdb->get_results(
$wpdb->prepare(
"SELECT
post_id,
SUM(cost) as total_cost,
COUNT(*) as call_count,
MAX(created_at) as last_call
FROM {$table_name}
WHERE {$where_clause}
GROUP BY post_id
ORDER BY post_id DESC
LIMIT %d OFFSET %d",
$per_page,
$offset,
),
ARRAY_A,
);
// Build grouped records with post details
$formatted_records = [];
$post_ids = [];
foreach ($grouped_records_sql as $row) {
$post_id = (int) $row["post_id"];
$post_ids[] = $post_id;
if ($post_id > 0) {
$post_title = get_the_title($post_id);
if (!$post_title) {
$post_title = sprintf(
__("[Removed Post #%d]", "wp-agentic-writer"),
$post_id,
);
$post_link = "";
} else {
$post_link = get_edit_post_link($post_id, "raw");
}
} else {
$post_title = __("System/Other", "wp-agentic-writer");
$post_link = "";
}
$formatted_records[] = [
"post_id" => $post_id,
"post_title" => $post_title,
"post_link" => $post_link,
"total_cost" => number_format((float) $row["total_cost"], 4),
"call_count" => (int) $row["call_count"],
"last_call" => date_i18n(
"Y-m-d H:i:s",
strtotime($row["last_call"]),
),
"details" => [], // Lazy-loaded on expand
];
}
// Load detail rows for visible posts.
// This keeps expand/collapse usable without requiring a second endpoint.
if (!empty($post_ids)) {
$placeholders = implode(",", array_fill(0, count($post_ids), "%d"));
$details_sql = $wpdb->prepare(
"SELECT post_id, created_at, model, action, input_tokens, output_tokens, cost
FROM {$table_name}
WHERE provider = 'openrouter' AND post_id IN ({$placeholders})
ORDER BY created_at DESC",
...$post_ids,
);
$detail_rows = $wpdb->get_results($details_sql, ARRAY_A);
$image_variants_table = $wpdb->prefix . "wpaw_images_variants";
$image_variants_table_exists =
$wpdb->get_var("SHOW TABLES LIKE '{$image_variants_table}'") ===
$image_variants_table;
if ($image_variants_table_exists) {
// Find posts that already have image_generation in wpaw_cost_tracking
// to avoid duplicates from the variants table.
$posts_with_tracked_images = [];
foreach ($detail_rows as $existing) {
if (($existing["action"] ?? "") === "image_generation") {
$posts_with_tracked_images[
(int) ($existing["post_id"] ?? 0)
] = true;
}
}
$variant_post_ids = array_filter($post_ids, function (
$pid,
) use ($posts_with_tracked_images) {
return empty($posts_with_tracked_images[(int) $pid]);
});
if (!empty($variant_post_ids)) {
$variant_placeholders = implode(
",",
array_fill(0, count($variant_post_ids), "%d"),
);
$image_details_sql = $wpdb->prepare(
"SELECT post_id, created_at, image_model_used AS model, cost
FROM {$image_variants_table}
WHERE post_id IN ({$variant_placeholders}) AND cost IS NOT NULL AND cost > 0
ORDER BY created_at DESC",
...$variant_post_ids,
);
$image_detail_rows = $wpdb->get_results(
$image_details_sql,
ARRAY_A,
);
foreach ($image_detail_rows as $image_detail_row) {
$detail_rows[] = [
"post_id" =>
(int) ($image_detail_row["post_id"] ?? 0),
"created_at" =>
$image_detail_row["created_at"] ?? "",
"model" => $image_detail_row["model"] ?? "",
"action" => "image_generation",
"input_tokens" => 0,
"output_tokens" => 0,
"cost" => $image_detail_row["cost"] ?? 0,
];
}
}
}
$detail_map = [];
foreach ($detail_rows as $detail_row) {
$pid = (int) ($detail_row["post_id"] ?? 0);
if (!isset($detail_map[$pid])) {
$detail_map[$pid] = [];
}
$detail_map[$pid][] = [
"created_at" => date_i18n(
"Y-m-d H:i:s",
strtotime($detail_row["created_at"]),
),
"model" => (string) ($detail_row["model"] ?? ""),
"action" => (string) ($detail_row["action"] ?? ""),
"input_tokens" => (int) ($detail_row["input_tokens"] ?? 0),
"output_tokens" =>
(int) ($detail_row["output_tokens"] ?? 0),
"cost" => number_format(
(float) ($detail_row["cost"] ?? 0),
4,
),
];
}
foreach ($formatted_records as $idx => $formatted_record) {
$pid = (int) ($formatted_record["post_id"] ?? 0);
$formatted_records[$idx]["details"] = $detail_map[$pid] ?? [];
$formatted_records[$idx]["details_total"] = count(
$formatted_records[$idx]["details"],
);
}
}
// Get summary stats (all-time aggregation in SQL)
$total_all_time = $wpdb->get_var(
"SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter'",
);
$month_start = date("Y-m-01 00:00:00");
$monthly_total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND created_at >= %s",
$month_start,
),
);
$today_total = $wpdb->get_var(
$wpdb->prepare(
"SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE provider = 'openrouter' AND DATE(created_at) = %s",
current_time("Y-m-d"),
),
);
$total_posts = $wpdb->get_var(
"SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE provider = 'openrouter' AND post_id > 0",
);
$avg_per_post = $total_posts > 0 ? $total_all_time / $total_posts : 0;
$action_summary_rows = $wpdb->get_results(
"SELECT action, COUNT(*) AS calls, COALESCE(SUM(cost), 0) AS total_cost, COALESCE(AVG(cost), 0) AS avg_cost
FROM {$table_name}
WHERE provider = 'openrouter'
GROUP BY action
ORDER BY total_cost DESC",
ARRAY_A,
);
$action_summary = [];
foreach ($action_summary_rows as $row) {
$action_summary[] = [
"action" => (string) ($row["action"] ?? ""),
"calls" => (int) ($row["calls"] ?? 0),
"total" => number_format((float) ($row["total_cost"] ?? 0), 4),
"average" => number_format((float) ($row["avg_cost"] ?? 0), 4),
];
}
// Get filter options (distinct values from DB)
$models = $wpdb->get_col(
"SELECT DISTINCT model FROM {$table_name} WHERE provider = 'openrouter' ORDER BY model LIMIT 100",
);
$types = $wpdb->get_col(
"SELECT DISTINCT action FROM {$table_name} WHERE provider = 'openrouter' ORDER BY action",
);
wp_send_json_success([
"records" => $formatted_records,
"total_items" => intval($total_items),
"total_pages" => intval($total_pages),
"current_page" => $page,
"per_page" => $per_page,
"stats" => [
"all_time" => number_format((float) $total_all_time, 4),
"monthly" => number_format((float) $monthly_total, 4),
"today" => number_format((float) $today_total, 4),
"avg_per_post" => number_format((float) $avg_per_post, 4),
"action_summary" => $action_summary,
],
"filters" => [
"models" => $models,
"types" => $types,
],
]);
}
/**
* AJAX handler for header statistics.
*
* @since 0.2.0
*/
public function ajax_get_header_stats()
{
if (!check_ajax_referer("wpaw_settings", "nonce", false)) {
wp_send_json_error(["message" => "Invalid nonce"]);
return;
}
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Permission denied"]);
return;
}
global $wpdb;
$table_name = $wpdb->prefix . "wpaw_cost_tracking";
// Check if table exists
$table_exists =
$wpdb->get_var("SHOW TABLES LIKE '{$table_name}'") === $table_name;
if (!$table_exists) {
wp_send_json_success([
"articles" => 0,
"total_cost" => "0.00",
"api_status" => "Not configured",
"api_online" => false,
"last_updated" => "Never",
]);
return;
}
// Get total articles
$total_articles = $wpdb->get_var(
"SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE post_id > 0",
);
// Get total cost
$total_cost = $wpdb->get_var("SELECT SUM(cost) FROM {$table_name}");
// Check API status
$settings = get_option("wp_agentic_writer_settings", []);
$api_key = $settings["openrouter_api_key"] ?? "";
$api_online = !empty($api_key);
// Get last activity
$last_activity = $wpdb->get_var(
"SELECT created_at FROM {$table_name} ORDER BY created_at DESC LIMIT 1",
);
$last_updated = $last_activity
? human_time_diff(
strtotime($last_activity),
current_time("timestamp"),
) . " ago"
: "Never";
wp_send_json_success([
"articles" => intval($total_articles),
"total_cost" => number_format((float) $total_cost, 2),
"api_status" => $api_online ? "Online" : "Not configured",
"api_online" => $api_online,
"last_updated" => $last_updated,
]);
}
/**
* AJAX handler for debugging models.
*
* @since 0.2.0
*/
public function ajax_debug_models()
{
if (!check_ajax_referer("wpaw_settings", "nonce", false)) {
wp_send_json_error(["message" => "Invalid nonce"]);
return;
}
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Permission denied"]);
return;
}
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
$models = $provider->get_cached_models();
if (is_wp_error($models)) {
wp_send_json_error(["message" => $models->get_error_message()]);
return;
}
// Check for specific models
$check_models = [
"deepseek/deepseek-chat-v3-0324",
"anthropic/claude-sonnet-4",
];
$found_models = [];
$missing_models = [];
foreach ($check_models as $check_id) {
$found = false;
foreach ($models as $model) {
if (isset($model["id"]) && $model["id"] === $check_id) {
$found = true;
$found_models[] = [
"id" => $model["id"],
"name" => $model["name"] ?? "N/A",
];
break;
}
}
if (!$found) {
$missing_models[] = $check_id;
}
}
wp_send_json_success([
"total_models" => count($models),
"found_models" => $found_models,
"missing_models" => $missing_models,
"sample_models" => array_slice(
array_map(function ($m) {
return [
"id" => $m["id"] ?? "N/A",
"name" => $m["name"] ?? "N/A",
];
}, $models),
0,
10,
),
]);
}
/**
* AJAX handler for testing API connection.
*
* @since 0.2.0
*/
public function ajax_test_api_connection()
{
if (!check_ajax_referer("wpaw_settings", "nonce", false)) {
wp_send_json_error(["message" => "Invalid nonce"]);
return;
}
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Permission denied"]);
return;
}
$settings = get_option("wp_agentic_writer_settings", []);
$posted_api_key = isset($_POST["api_key"])
? trim(sanitize_text_field(wp_unslash($_POST["api_key"])))
: "";
$api_key = !empty($posted_api_key)
? $posted_api_key
: $settings["openrouter_api_key"] ?? "";
if (empty($api_key)) {
wp_send_json_error(["message" => "API key is not configured"]);
return;
}
// Test API connection by making a simple request
$response = wp_remote_get(
"https://openrouter.ai/api/v1/models?output_modalities=all",
[
"headers" => [
"Authorization" => "Bearer " . $api_key,
"HTTP-Referer" => home_url(),
],
"timeout" => 10,
],
);
if (is_wp_error($response)) {
wp_send_json_error([
"message" =>
"Connection failed: " . $response->get_error_message(),
]);
return;
}
$status_code = wp_remote_retrieve_response_code($response);
$body = wp_remote_retrieve_body($response);
if (200 === $status_code) {
$data = json_decode($body, true);
if (isset($data["data"]) && is_array($data["data"])) {
wp_send_json_success([
"message" => "API connection successful!",
"models_count" => count($data["data"]),
]);
return;
}
}
// Handle error responses
if (401 === $status_code) {
wp_send_json_error(["message" => "Invalid API key"]);
return;
}
if (403 === $status_code) {
wp_send_json_error([
"message" =>
"Access forbidden - check your API key permissions",
]);
return;
}
wp_send_json_error([
"message" =>
"API connection failed with status code: " . $status_code,
]);
}
/**
* Add settings page to admin menu.
*
* @since 0.2.0
*/
public function add_settings_page()
{
add_options_page(
__("WP Agentic Writer", "wp-agentic-writer"),
__("Agentic Writer", "wp-agentic-writer"),
"manage_options",
"wp-agentic-writer-settings",
[$this, "render_settings_page"],
);
}
/**
* Register settings.
*
* @since 0.2.0
*/
public function register_settings()
{
register_setting(
"wp_agentic_writer_settings",
"wp_agentic_writer_settings",
[
"sanitize_callback" => [$this, "sanitize_settings"],
],
);
}
/**
* Sanitize settings.
*
* @since 0.2.0
* @param array $input Settings input.
* @return array Sanitized settings.
*/
public function sanitize_settings($input)
{
$sanitized = [];
// Sanitize API keys (allow empty values to clear them)
if (isset($input["openrouter_api_key"])) {
$sanitized["openrouter_api_key"] = trim(
$input["openrouter_api_key"],
);
}
if (isset($input["brave_search_api_key"])) {
$sanitized["brave_search_api_key"] = trim(
$input["brave_search_api_key"],
);
}
if (isset($input["custom_search_url"])) {
$sanitized["custom_search_url"] = esc_url_raw(
trim($input["custom_search_url"]),
);
}
// Sanitize model names (6 models) - using model registry for defaults
$sanitized["chat_model"] = sanitize_text_field(
$input["chat_model"] ??
WPAW_Model_Registry::get_default_model("chat"),
);
$sanitized["clarity_model"] = sanitize_text_field(
$input["clarity_model"] ??
WPAW_Model_Registry::get_default_model("clarity"),
);
$sanitized["planning_model"] = sanitize_text_field(
$input["planning_model"] ??
WPAW_Model_Registry::get_default_model("planning"),
);
$sanitized["writing_model"] = sanitize_text_field(
$input["writing_model"] ??
WPAW_Model_Registry::get_default_model("writing"),
);
$sanitized["refinement_model"] = sanitize_text_field(
$input["refinement_model"] ??
WPAW_Model_Registry::get_default_model("refinement"),
);
$sanitized["image_model"] = sanitize_text_field(
$input["image_model"] ??
WPAW_Model_Registry::get_default_model("image"),
);
// Legacy support: map execution_model to writing_model
if (
isset($input["execution_model"]) &&
!isset($input["writing_model"])
) {
$sanitized["writing_model"] = sanitize_text_field(
$input["execution_model"],
);
}
// Sanitize boolean values
$sanitized["web_search_enabled"] =
isset($input["web_search_enabled"]) &&
"1" === $input["web_search_enabled"];
$sanitized["cost_tracking_enabled"] =
isset($input["cost_tracking_enabled"]) &&
"1" === $input["cost_tracking_enabled"];
$sanitized["enable_clarification_quiz"] =
isset($input["enable_clarification_quiz"]) &&
"1" === $input["enable_clarification_quiz"];
$sanitized["enable_faq_schema"] = isset($input["enable_faq_schema"])
? "1" === $input["enable_faq_schema"]
: false;
$sanitized["allow_openrouter_fallback"] =
isset($input["allow_openrouter_fallback"]) &&
"1" === $input["allow_openrouter_fallback"];
$sanitized["openrouter_provider_routing_enabled"] =
isset($input["openrouter_provider_routing_enabled"]) &&
"1" === $input["openrouter_provider_routing_enabled"];
$sanitized["openrouter_provider_only"] =
isset($input["openrouter_provider_only"]) &&
"1" === $input["openrouter_provider_only"];
$sanitized["openrouter_allow_provider_fallbacks"] =
isset($input["openrouter_allow_provider_fallbacks"]) &&
"1" === $input["openrouter_allow_provider_fallbacks"];
$provider_slug = isset($input["openrouter_provider_slug"])
? sanitize_key($input["openrouter_provider_slug"])
: "auto";
$sanitized["openrouter_provider_slug"] =
"" !== $provider_slug ? $provider_slug : "auto";
// Sanitize search options
$sanitized["search_engine"] = in_array(
$input["search_engine"] ?? "",
["auto", "native", "exa"],
true,
)
? $input["search_engine"]
: "auto";
$sanitized["search_depth"] =
isset($input["search_depth"]) &&
in_array($input["search_depth"], ["low", "medium", "high"], true)
? $input["search_depth"]
: "medium";
// Sanitize budget
$sanitized["monthly_budget"] = floatval(
$input["monthly_budget"] ?? 600,
);
// Sanitize chat history limit
$chat_history_limit = isset($input["chat_history_limit"])
? absint($input["chat_history_limit"])
: 20;
$sanitized["chat_history_limit"] = min($chat_history_limit, 200);
// Sanitize clarification quiz settings
$sanitized["clarity_confidence_threshold"] = in_array(
$input["clarity_confidence_threshold"] ?? "",
["0.5", "0.6", "0.7", "0.8", "0.9"],
true,
)
? $input["clarity_confidence_threshold"]
: "0.6";
if (
isset($input["required_context_categories"]) &&
is_array($input["required_context_categories"])
) {
$valid_categories = [
"target_outcome",
"target_audience",
"tone",
"content_depth",
"expertise_level",
"content_type",
"pov",
];
$sanitized["required_context_categories"] = array_intersect(
$input["required_context_categories"],
$valid_categories,
);
} else {
$sanitized["required_context_categories"] = [
"target_outcome",
"target_audience",
"tone",
"content_depth",
"expertise_level",
"content_type",
"pov",
];
}
// Sanitize preferred languages
if (
isset($input["preferred_languages"]) &&
is_array($input["preferred_languages"])
) {
$sanitized["preferred_languages"] = array_map(
"sanitize_text_field",
$input["preferred_languages"],
);
} else {
$sanitized["preferred_languages"] = [
"auto",
"English",
"Indonesian",
];
}
// Sanitize custom languages
if (
isset($input["custom_languages"]) &&
is_array($input["custom_languages"])
) {
$custom_language_values = [];
foreach ($input["custom_languages"] as $custom_language_value) {
$custom_language_values = array_merge(
$custom_language_values,
array_map(
"trim",
explode(",", (string) $custom_language_value),
),
);
}
$sanitized["custom_languages"] = array_filter(
array_map("sanitize_text_field", $custom_language_values),
);
} else {
$sanitized["custom_languages"] = [];
}
// Sanitize Global Context
$sanitized["global_context"] = isset($input["global_context"])
? sanitize_textarea_field(trim($input["global_context"]))
: "";
// Sanitize Custom Endpoint settings (Fix for settings wiping out)
if (isset($input["local_backend_url"])) {
$sanitized["local_backend_url"] = esc_url_raw(
trim($input["local_backend_url"]),
);
}
if (isset($input["local_backend_key"])) {
$sanitized["local_backend_key"] = sanitize_text_field(
trim($input["local_backend_key"]),
);
}
if (isset($input["local_backend_image_url"])) {
$sanitized["local_backend_image_url"] = esc_url_raw(
trim($input["local_backend_image_url"]),
);
}
if (isset($input["local_backend_image_key"])) {
$sanitized["local_backend_image_key"] = sanitize_text_field(
trim($input["local_backend_image_key"]),
);
}
if (isset($input["local_backend_model"])) {
$sanitized["local_backend_model"] = sanitize_text_field(
trim($input["local_backend_model"]),
);
}
$sanitized["local_backend_enabled"] =
isset($input["local_backend_enabled"]) &&
"1" === $input["local_backend_enabled"];
$sanitized["local_backend_image_enabled"] =
isset($input["local_backend_image_enabled"]) &&
"1" === $input["local_backend_image_enabled"];
// Per-task model overrides for custom endpoint
if (
isset($input["local_backend_models"]) &&
is_array($input["local_backend_models"])
) {
$sanitized_models = [];
$allowed_model_tasks = [
"chat",
"clarity",
"planning",
"writing",
"refinement",
"image",
];
foreach ($input["local_backend_models"] as $task => $model_code) {
$task = sanitize_text_field($task);
if (in_array($task, $allowed_model_tasks, true)) {
$sanitized_models[$task] = sanitize_text_field(
trim($model_code),
);
}
}
$sanitized["local_backend_models"] = $sanitized_models;
}
// Sanitize MEMANTO settings.
$sanitized["memanto_enabled"] =
isset($input["memanto_enabled"]) &&
"1" === $input["memanto_enabled"];
$sanitized["memanto_url"] = isset($input["memanto_url"])
? esc_url_raw(trim($input["memanto_url"]))
: "";
$sanitized["memanto_license_key"] = isset($input["memanto_license_key"])
? sanitize_text_field(trim($input["memanto_license_key"]))
: "";
$sanitized["memanto_moorcheh_key"] = isset(
$input["memanto_moorcheh_key"],
)
? sanitize_text_field(trim($input["memanto_moorcheh_key"]))
: "";
// Sanitize Task Providers Routing.
// When custom endpoint is enabled, auto-build task_providers from
// the per-task model codes: a non-empty model code ⇒ local_backend.
if (!empty($sanitized["local_backend_enabled"])) {
$sanitized_providers = [];
$lb_models = $sanitized["local_backend_models"] ?? [];
$all_tasks = [
"chat",
"clarity",
"planning",
"writing",
"refinement",
"image",
];
foreach ($all_tasks as $task) {
if ($task === "image") {
$sanitized_providers[$task] = !empty(
$sanitized["local_backend_image_enabled"]
)
? "local_backend"
: "openrouter";
} else {
$sanitized_providers[$task] = !empty($lb_models[$task])
? "local_backend"
: "openrouter";
}
}
$sanitized["task_providers"] = $sanitized_providers;
} elseif (
isset($input["task_providers"]) &&
is_array($input["task_providers"])
) {
$sanitized_providers = [];
$allowed_tasks = [
"chat",
"clarity",
"planning",
"writing",
"refinement",
"image",
];
$allowed_providers_text = ["openrouter", "local_backend", "codex"];
foreach ($input["task_providers"] as $task => $provider) {
$task = sanitize_text_field($task);
$provider = sanitize_text_field($provider);
if (in_array($task, $allowed_tasks, true)) {
if (in_array($provider, $allowed_providers_text, true)) {
$sanitized_providers[$task] = $provider;
}
}
}
$sanitized["task_providers"] = $sanitized_providers;
}
return $sanitized;
}
/**
* Render settings page - main entry point.
*
* @since 0.2.0
*/
public function render_settings_page()
{
$settings = get_option("wp_agentic_writer_settings", []);
// Extract settings for views
$view_data = $this->prepare_view_data($settings);
// Include Stitch rebuild layout
include WP_AGENTIC_WRITER_DIR . "views/settings-v2/layout.php";
}
/**
* Prepare data for view files.
*
* @since 0.2.0
* @param array $settings Plugin settings.
* @return array View data.
*/
private function prepare_view_data($settings)
{
// Extract settings (6 models) using model registry for defaults
$api_key = $settings["openrouter_api_key"] ?? "";
$brave_search_api_key = $settings["brave_search_api_key"] ?? "";
$custom_search_url = $settings["custom_search_url"] ?? "";
$chat_model =
$settings["chat_model"] ??
WPAW_Model_Registry::get_default_model("chat");
$clarity_model =
$settings["clarity_model"] ??
WPAW_Model_Registry::get_default_model("clarity");
$planning_model =
$settings["planning_model"] ??
WPAW_Model_Registry::get_default_model("planning");
$writing_model =
$settings["writing_model"] ??
($settings["execution_model"] ??
WPAW_Model_Registry::get_default_model("writing"));
$refinement_model =
$settings["refinement_model"] ??
WPAW_Model_Registry::get_default_model("refinement");
$image_model =
$settings["image_model"] ??
WPAW_Model_Registry::get_default_model("image");
$web_search_enabled = $settings["web_search_enabled"] ?? false;
$search_engine = $settings["search_engine"] ?? "auto";
$search_depth = $settings["search_depth"] ?? "medium";
$cost_tracking_enabled = $settings["cost_tracking_enabled"] ?? true;
$monthly_budget = $settings["monthly_budget"] ?? 600;
$chat_history_limit = $settings["chat_history_limit"] ?? 20;
$enable_clarification_quiz =
$settings["enable_clarification_quiz"] ?? true;
$enable_faq_schema = $settings["enable_faq_schema"] ?? false;
$clarity_confidence_threshold =
$settings["clarity_confidence_threshold"] ?? "0.6";
$required_context_categories = $settings[
"required_context_categories"
] ?? [
"target_outcome",
"target_audience",
"tone",
"content_depth",
"expertise_level",
"content_type",
"pov",
];
$preferred_languages = $settings["preferred_languages"] ?? [
"auto",
"English",
"Indonesian",
];
$custom_languages = $settings["custom_languages"] ?? [];
$global_context = $settings["global_context"] ?? "";
$available_languages = $this->get_available_languages();
$custom_models = get_option("wp_agentic_writer_custom_models", []);
// Custom Endpoint settings
$local_backend_url = $settings["local_backend_url"] ?? "";
$local_backend_key = $settings["local_backend_key"] ?? "";
$local_backend_image_url = $settings["local_backend_image_url"] ?? "";
$local_backend_image_key = $settings["local_backend_image_key"] ?? "";
$local_backend_model = $settings["local_backend_model"] ?? "";
$local_backend_enabled = !empty($settings["local_backend_enabled"]);
$local_backend_image_enabled = !empty(
$settings["local_backend_image_enabled"]
);
$local_backend_models = $settings["local_backend_models"] ?? [];
// MEMANTO settings
$memanto_enabled = $settings["memanto_enabled"] ?? false;
$memanto_url = $settings["memanto_url"] ?? "";
$memanto_license_key = $settings["memanto_license_key"] ?? "";
$memanto_moorcheh_key = $settings["memanto_moorcheh_key"] ?? "";
$task_providers = $settings["task_providers"] ?? [];
$allow_openrouter_fallback = !empty(
$settings["allow_openrouter_fallback"]
);
$openrouter_provider_routing_enabled = !empty(
$settings["openrouter_provider_routing_enabled"]
);
$openrouter_provider_slug =
$settings["openrouter_provider_slug"] ?? "auto";
$openrouter_provider_only = !empty(
$settings["openrouter_provider_only"]
);
$openrouter_allow_provider_fallbacks = !empty(
$settings["openrouter_allow_provider_fallbacks"]
);
// Get cost tracking data
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
$monthly_used = $cost_tracker->get_monthly_total();
$budget_percent =
$monthly_budget > 0 ? ($monthly_used / $monthly_budget) * 100 : 0;
$budget_status =
$budget_percent > 90
? "danger"
: ($budget_percent > 70
? "warning"
: "success");
return compact(
"api_key",
"brave_search_api_key",
"custom_search_url",
"chat_model",
"clarity_model",
"planning_model",
"writing_model",
"refinement_model",
"image_model",
"web_search_enabled",
"search_engine",
"search_depth",
"cost_tracking_enabled",
"monthly_budget",
"chat_history_limit",
"enable_clarification_quiz",
"enable_faq_schema",
"clarity_confidence_threshold",
"required_context_categories",
"preferred_languages",
"custom_languages",
"global_context",
"available_languages",
"custom_models",
"monthly_used",
"budget_percent",
"budget_status",
"local_backend_url",
"local_backend_key",
"local_backend_image_url",
"local_backend_image_key",
"local_backend_model",
"local_backend_enabled",
"local_backend_image_enabled",
"local_backend_models",
"task_providers",
"allow_openrouter_fallback",
"openrouter_provider_routing_enabled",
"openrouter_provider_slug",
"openrouter_provider_only",
"openrouter_allow_provider_fallbacks",
"memanto_enabled",
"memanto_url",
"memanto_license_key",
"memanto_moorcheh_key",
"settings",
);
}
/**
* Get available languages list.
*
* @since 0.2.0
* @return array Available languages.
*/
public function get_available_languages()
{
return [
"auto" => "Auto-detect",
"English" => "English",
"Indonesian" => "Indonesian (Bahasa Indonesia)",
"Javanese" => "Javanese (Basa Jawa)",
"Sundanese" => "Sundanese (Basa Sunda)",
"Spanish" => "Spanish (Español)",
"French" => "French (Français)",
"Arabic" => "Arabic (العربية)",
"Chinese" => "Chinese (中文)",
"Japanese" => "Japanese (日本語)",
"Portuguese" => "Portuguese (Português)",
"German" => "German (Deutsch)",
"Hindi" => "Hindi (हिंदी)",
"Korean" => "Korean (한국어)",
"Vietnamese" => "Vietnamese (Tiếng Việt)",
"Thai" => "Thai (ไทย)",
"Tagalog" => "Tagalog",
"Malay" => "Malay (Bahasa Melayu)",
"Russian" => "Russian (Русский)",
"Italian" => "Italian (Italiano)",
"Dutch" => "Dutch (Nederlands)",
"Polish" => "Polish (Polski)",
"Turkish" => "Turkish (Türkçe)",
"Swedish" => "Swedish (Svenska)",
];
}
/**
* AJAX handler: Test local backend connection
*
* @since 0.2.0
*/
public function ajax_test_local_backend()
{
check_ajax_referer("wpaw_settings", "nonce");
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Insufficient permissions"]);
}
$url = sanitize_text_field(wp_unslash($_POST["url"] ?? ""));
$key = sanitize_text_field(wp_unslash($_POST["key"] ?? ""));
$model = sanitize_text_field(wp_unslash($_POST["model"] ?? ""));
if (empty($url)) {
wp_send_json_error(["message" => "URL required"]);
}
// Temporarily create provider with these form values.
$original_settings = get_option("wp_agentic_writer_settings", []);
$temp_settings = $original_settings;
$temp_settings["local_backend_url"] = $url;
if ("" !== $key) {
$temp_settings["local_backend_key"] = $key;
}
if ("" !== $model) {
$temp_settings["local_backend_model"] = $model;
}
update_option("wp_agentic_writer_settings", $temp_settings);
$provider = new WP_Agentic_Writer_Local_Backend_Provider();
$result = $provider->test_connection();
update_option("wp_agentic_writer_settings", $original_settings);
if (is_wp_error($result)) {
wp_send_json_error(["message" => $result->get_error_message()]);
}
wp_send_json_success($result);
}
/**
* AJAX handler: Test MEMANTO connection.
*
* Uses the provided URL and key (from the form) to test connectivity
* without requiring settings to be saved first.
*
* @since 0.3.0
*/
public function ajax_test_memanto()
{
check_ajax_referer("wpaw_settings", "nonce");
if (!current_user_can("manage_options")) {
wp_send_json_error(["message" => "Insufficient permissions"]);
}
$url = sanitize_text_field(wp_unslash($_POST["url"] ?? ""));
$key = sanitize_text_field(wp_unslash($_POST["key"] ?? ""));
if (empty($url) || empty($key)) {
wp_send_json_error([
"message" => "MEMANTO URL and Moorcheh API key are required.",
]);
}
// Temporarily override settings so the client uses the form values.
$original_settings = get_option("wp_agentic_writer_settings", []);
$temp_settings = $original_settings;
$temp_settings["memanto_url"] = esc_url_raw(trim($url));
$temp_settings["memanto_moorcheh_key"] = sanitize_text_field(
trim($key),
);
update_option("wp_agentic_writer_settings", $temp_settings);
// Clear health cache so the fresh URL/key are used.
delete_transient("wpaw_memanto_health");
$client = WP_Agentic_Writer_Memanto_Client::for_base_url(
esc_url_raw(trim($url)),
);
$result = $client->check_health_fresh();
update_option("wp_agentic_writer_settings", $original_settings);
delete_transient("wpaw_memanto_health");
if ($result["healthy"]) {
wp_send_json_success($result);
} else {
$error_msg = "Connection failed.";
if (!empty($result["details"]["error"])) {
$error_msg = $result["details"]["error"];
}
wp_send_json_error(["message" => $error_msg, "healthy" => false]);
}
}
}