Add AI writing assistant plugin with local backend, brave search, and image generation support
- Implement local backend AI provider with Ollama integration - Add Brave Search API integration for real-time search suggestions - Add image generation manager with multiple AI providers - Create hybrid provider system with local/cloud fallback - Add comprehensive settings UI with provider management - Implement Gutenberg sidebar with writing assistance controls - Add SEO schema generation for AI-generated content - Multiple provider support: OpenRouter, local backend, Codex
This commit is contained in:
@@ -59,7 +59,7 @@ class WP_Agentic_Writer_Admin_Columns {
|
||||
$new_columns = array();
|
||||
foreach ( $columns as $key => $value ) {
|
||||
if ( $key === 'date' ) {
|
||||
$new_columns['wp_aw_cost'] = '💰 AI Cost';
|
||||
$new_columns['wp_aw_cost'] = '💰 OpenRouter Cost';
|
||||
}
|
||||
$new_columns[ $key ] = $value;
|
||||
}
|
||||
|
||||
154
includes/class-brave-search-api.php
Normal file
154
includes/class-brave-search-api.php
Normal file
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* Brave Search API Integration
|
||||
*
|
||||
* Handles fetching web search results for models that do not natively support web search
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Brave_Search_API {
|
||||
|
||||
/**
|
||||
* Brave Search REST API Endpoint
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $api_endpoint = 'https://api.search.brave.com/res/v1/web/search';
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_Agentic_Writer_Brave_Search_API
|
||||
*/
|
||||
public static function get_instance() {
|
||||
static $instance = null;
|
||||
|
||||
if ( null === $instance ) {
|
||||
$instance = new self();
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a web search.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $query Required. The user's search query.
|
||||
* @param int $count Optional. Number of results to return. Default 3.
|
||||
* @return array|WP_Error Array of formatted search results, or WP_Error on failure.
|
||||
*/
|
||||
public function search( $query, $count = 3 ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$api_key = $settings['brave_search_api_key'] ?? '';
|
||||
|
||||
if ( empty( $api_key ) ) {
|
||||
return new WP_Error(
|
||||
'brave_api_key_missing',
|
||||
__( 'Brave Search API Key is missing. Please configure it in WP Agentic Writer settings.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
// Check cache first to prevent burning API limits on identical subsequent queries
|
||||
$cache_key = 'wpaw_brave_search_' . md5( $query . '_' . $count );
|
||||
$cached_results = get_transient( $cache_key );
|
||||
if ( false !== $cached_results ) {
|
||||
return $cached_results;
|
||||
}
|
||||
|
||||
$url = add_query_arg(
|
||||
array(
|
||||
'q' => urlencode( $query ),
|
||||
'count' => absint( $count ),
|
||||
'text_decorations' => 0, // Disable HTML tags in descriptions
|
||||
'spellcheck' => 1,
|
||||
),
|
||||
$this->api_endpoint
|
||||
);
|
||||
|
||||
$response = wp_remote_get(
|
||||
$url,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Accept' => 'application/json',
|
||||
'Accept-Encoding' => 'gzip',
|
||||
'X-Subscription-Token' => $api_key,
|
||||
),
|
||||
'timeout' => 15,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$http_code = wp_remote_retrieve_response_code( $response );
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( 200 !== $http_code ) {
|
||||
return new WP_Error(
|
||||
'brave_api_error',
|
||||
sprintf(
|
||||
/* translators: %1$d is HTTP status code, %2$s is error message */
|
||||
__( 'Brave Search API Error %1$d: %2$s', 'wp-agentic-writer' ),
|
||||
$http_code,
|
||||
$body['message'] ?? __( 'Unknown Error', 'wp-agentic-writer' )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if ( empty( $body['web']['results'] ) ) {
|
||||
return array(); // No results found
|
||||
}
|
||||
|
||||
$formatted_results = array();
|
||||
foreach ( $body['web']['results'] as $result ) {
|
||||
$formatted_results[] = array(
|
||||
'title' => $result['title'] ?? '',
|
||||
'url' => $result['url'] ?? '',
|
||||
'description' => $result['description'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
// Cache results for 1 hour to prevent redundant API calls
|
||||
set_transient( $cache_key, $formatted_results, HOUR_IN_SECONDS );
|
||||
|
||||
return $formatted_results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats search results into a markdown context block for LLM System Prompt injection.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $results Search results array.
|
||||
* @param string $query Original query.
|
||||
* @return string Formatted markdown context string.
|
||||
*/
|
||||
public function format_results_for_llm( $results, $query ) {
|
||||
if ( empty( $results ) || is_wp_error( $results ) ) {
|
||||
return "No reliable web search results found for: {$query}";
|
||||
}
|
||||
|
||||
$markdown = "## LIVE WEB SEARCH CONTEXT\n";
|
||||
$markdown .= "> You successfully searched the internet for: \"{$query}\"\n";
|
||||
$markdown .= "> Please incorporate the following real-time data into your answer:\n\n";
|
||||
|
||||
$counter = 1;
|
||||
foreach ( $results as $item ) {
|
||||
$markdown .= "{$counter}. **{$item['title']}**\n";
|
||||
$markdown .= " URL: {$item['url']}\n";
|
||||
$markdown .= " Summary: {$item['description']}\n\n";
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$markdown .= "---------------------------\n";
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
}
|
||||
384
includes/class-codex-provider.php
Normal file
384
includes/class-codex-provider.php
Normal file
@@ -0,0 +1,384 @@
|
||||
<?php
|
||||
/**
|
||||
* Codex Provider (OpenAI API)
|
||||
*
|
||||
* Direct integration with OpenAI's API for text generation
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Codex_Provider implements WP_Agentic_Writer_AI_Provider_Interface {
|
||||
|
||||
/**
|
||||
* OpenAI API key
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $api_key = '';
|
||||
|
||||
/**
|
||||
* API endpoint
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $api_endpoint = 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
/**
|
||||
* Default model
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $model = 'gpt-4o';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$this->api_key = $settings['codex_api_key'] ?? '';
|
||||
$this->model = $settings['codex_model'] ?? 'gpt-4o';
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat completion
|
||||
*
|
||||
* @param array $messages Array of message objects.
|
||||
* @param array $options Optional parameters.
|
||||
* @param string $type Task type.
|
||||
* @return array|WP_Error Response with content, model, tokens, cost.
|
||||
*/
|
||||
public function chat( $messages, $options = array(), $type = 'planning' ) {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return new WP_Error(
|
||||
'not_configured',
|
||||
__( 'Codex API key not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$model = $options['model'] ?? $this->model;
|
||||
$temperature = $options['temperature'] ?? 0.7;
|
||||
$max_tokens = $options['max_tokens'] ?? null;
|
||||
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => $temperature,
|
||||
);
|
||||
|
||||
if ( $max_tokens ) {
|
||||
$body['max_tokens'] = $max_tokens;
|
||||
}
|
||||
|
||||
$start_time = microtime( true );
|
||||
|
||||
$response = wp_remote_post(
|
||||
$this->api_endpoint,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
),
|
||||
'body' => wp_json_encode( $body ),
|
||||
'timeout' => 120,
|
||||
)
|
||||
);
|
||||
|
||||
$generation_time = microtime( true ) - $start_time;
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error(
|
||||
'connection_failed',
|
||||
sprintf(
|
||||
/* translators: %s: error message */
|
||||
__( 'Codex connection failed: %s', 'wp-agentic-writer' ),
|
||||
$response->get_error_message()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
$body_response = wp_remote_retrieve_body( $response );
|
||||
$error_data = json_decode( $body_response, true );
|
||||
return new WP_Error(
|
||||
'api_error',
|
||||
sprintf(
|
||||
/* translators: %1$d: HTTP status code, %2$s: error message */
|
||||
__( 'Codex error (%1$d): %2$s', 'wp-agentic-writer' ),
|
||||
$code,
|
||||
$error_data['error']['message'] ?? $body_response
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$response_body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( ! isset( $response_body['choices'][0]['message']['content'] ) ) {
|
||||
return new WP_Error(
|
||||
'invalid_response',
|
||||
__( 'Invalid response format from Codex', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$content = $response_body['choices'][0]['message']['content'];
|
||||
$usage = $response_body['usage'] ?? array();
|
||||
|
||||
// Calculate cost based on OpenAI pricing
|
||||
$cost = $this->calculate_cost( $model, $usage );
|
||||
|
||||
return array(
|
||||
'content' => $content,
|
||||
'model' => $model,
|
||||
'input_tokens' => $usage['prompt_tokens'] ?? 0,
|
||||
'output_tokens' => $usage['completion_tokens'] ?? 0,
|
||||
'total_tokens' => $usage['total_tokens'] ?? 0,
|
||||
'cost' => $cost,
|
||||
'generation_time' => $generation_time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat completion
|
||||
*
|
||||
* @param array $messages Array of message objects.
|
||||
* @param array $options Optional parameters.
|
||||
* @param string $type Task type.
|
||||
* @param callable $callback Function to call with each chunk.
|
||||
* @return array|WP_Error Response or error.
|
||||
*/
|
||||
public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return new WP_Error(
|
||||
'not_configured',
|
||||
__( 'Codex API key not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$model = $options['model'] ?? $this->model;
|
||||
$temperature = $options['temperature'] ?? 0.7;
|
||||
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'temperature' => $temperature,
|
||||
'stream' => true,
|
||||
);
|
||||
|
||||
if ( isset( $options['max_tokens'] ) ) {
|
||||
$body['max_tokens'] = $options['max_tokens'];
|
||||
}
|
||||
|
||||
$accumulated_content = '';
|
||||
$accumulated_usage = array();
|
||||
$buffer = '';
|
||||
|
||||
$accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, $callback ) {
|
||||
if ( ! $is_complete && ! empty( $chunk ) ) {
|
||||
$accumulated_content .= $chunk;
|
||||
}
|
||||
|
||||
if ( $callback ) {
|
||||
call_user_func( $callback, $chunk, $is_complete, $accumulated_content );
|
||||
}
|
||||
};
|
||||
|
||||
$ch = curl_init( $this->api_endpoint );
|
||||
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) {
|
||||
$buffer .= $data;
|
||||
|
||||
while ( true ) {
|
||||
$newline_pos = strpos( $buffer, "\n" );
|
||||
if ( false === $newline_pos ) {
|
||||
break;
|
||||
}
|
||||
|
||||
$line = substr( $buffer, 0, $newline_pos );
|
||||
$buffer = substr( $buffer, $newline_pos + 1 );
|
||||
|
||||
$line = trim( $line );
|
||||
if ( empty( $line ) || ! str_starts_with( $line, 'data: ' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json_str = substr( $line, 6 );
|
||||
|
||||
if ( '[DONE]' === $json_str ) {
|
||||
call_user_func( $accumulating_callback, '', true );
|
||||
return strlen( $data );
|
||||
}
|
||||
|
||||
$chunk = json_decode( $json_str, true );
|
||||
if ( isset( $chunk['choices'][0]['delta']['content'] ) ) {
|
||||
$content = $chunk['choices'][0]['delta']['content'];
|
||||
call_user_func( $accumulating_callback, $content, false );
|
||||
}
|
||||
|
||||
if ( isset( $chunk['usage'] ) ) {
|
||||
$accumulated_usage = $chunk['usage'];
|
||||
}
|
||||
}
|
||||
|
||||
return strlen( $data );
|
||||
},
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Bearer ' . $this->api_key,
|
||||
'Content-Type: application/json',
|
||||
),
|
||||
CURLOPT_POSTFIELDS => wp_json_encode( $body ),
|
||||
CURLOPT_TIMEOUT => 180,
|
||||
) );
|
||||
|
||||
$start_time = microtime( true );
|
||||
$result = curl_exec( $ch );
|
||||
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$curl_error = curl_error( $ch );
|
||||
curl_close( $ch );
|
||||
|
||||
if ( false === $result && ! empty( $curl_error ) ) {
|
||||
return new WP_Error( 'curl_error', 'cURL error: ' . $curl_error );
|
||||
}
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
return new WP_Error( 'api_error', sprintf( 'API error (%d)', $http_code ) );
|
||||
}
|
||||
|
||||
$cost = $this->calculate_cost( $model, $accumulated_usage );
|
||||
|
||||
return array(
|
||||
'content' => $accumulated_content,
|
||||
'model' => $model,
|
||||
'input_tokens' => $accumulated_usage['prompt_tokens'] ?? 0,
|
||||
'output_tokens' => $accumulated_usage['completion_tokens'] ?? 0,
|
||||
'total_tokens' => $accumulated_usage['total_tokens'] ?? 0,
|
||||
'cost' => $cost,
|
||||
'generation_time' => microtime( true ) - $start_time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image (not supported by Codex)
|
||||
*
|
||||
* @param string $prompt Image prompt.
|
||||
* @param string $model Model to use.
|
||||
* @param array $options Optional parameters.
|
||||
* @return WP_Error Error indicating not supported.
|
||||
*/
|
||||
public function generate_image( $prompt, $model = null, $options = array() ) {
|
||||
return new WP_Error(
|
||||
'not_supported',
|
||||
__( 'Image generation not supported by Codex. Use OpenRouter for images.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is configured
|
||||
*
|
||||
* @return bool True if API key is set.
|
||||
*/
|
||||
public function is_configured() {
|
||||
return ! empty( $this->api_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to Codex API
|
||||
*
|
||||
* @return array|WP_Error Success array or error.
|
||||
*/
|
||||
public function test_connection() {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return new WP_Error( 'not_configured', 'Codex API key not configured' );
|
||||
}
|
||||
|
||||
$response = wp_remote_post(
|
||||
$this->api_endpoint,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
),
|
||||
'body' => wp_json_encode(
|
||||
array(
|
||||
'model' => $this->model,
|
||||
'messages' => array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => 'Reply with: Test successful',
|
||||
),
|
||||
),
|
||||
'max_tokens' => 10,
|
||||
)
|
||||
),
|
||||
'timeout' => 15,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
return new WP_Error(
|
||||
'connection_failed',
|
||||
$body['error']['message'] ?? sprintf( 'API returned status %d', $code )
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => 'Connected to OpenAI Codex successfully',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider supports task type
|
||||
*
|
||||
* @param string $type Task type.
|
||||
* @return bool True if supported (all text tasks).
|
||||
*/
|
||||
public function supports_task_type( $type ) {
|
||||
return in_array(
|
||||
$type,
|
||||
array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost based on OpenAI pricing
|
||||
*
|
||||
* @param string $model Model identifier.
|
||||
* @param array $usage Usage data with prompt_tokens and completion_tokens.
|
||||
* @return float Cost in USD.
|
||||
*/
|
||||
private function calculate_cost( $model, $usage ) {
|
||||
if ( empty( $usage['prompt_tokens'] ) && empty( $usage['completion_tokens'] ) ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// OpenAI pricing (as of 2024, per 1M tokens)
|
||||
$pricing = array(
|
||||
'gpt-4o' => array( 'input' => 2.50, 'output' => 10.00 ),
|
||||
'gpt-4o-mini' => array( 'input' => 0.15, 'output' => 0.60 ),
|
||||
'gpt-4-turbo' => array( 'input' => 10.00, 'output' => 30.00 ),
|
||||
'gpt-4' => array( 'input' => 30.00, 'output' => 60.00 ),
|
||||
'gpt-3.5-turbo' => array( 'input' => 0.50, 'output' => 1.50 ),
|
||||
);
|
||||
|
||||
$rates = $pricing[ $model ] ?? $pricing['gpt-4o'];
|
||||
|
||||
$input_cost = ( $usage['prompt_tokens'] ?? 0 ) * ( $rates['input'] / 1000000 );
|
||||
$output_cost = ( $usage['completion_tokens'] ?? 0 ) * ( $rates['output'] / 1000000 );
|
||||
|
||||
return $input_cost + $output_cost;
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,39 @@ class WP_Agentic_Writer_Gutenberg_Sidebar {
|
||||
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',
|
||||
array(
|
||||
'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',
|
||||
array(
|
||||
'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 sidebar styles.
|
||||
$style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/sidebar.css';
|
||||
wp_enqueue_style(
|
||||
@@ -464,6 +497,37 @@ class WP_Agentic_Writer_Gutenberg_Sidebar {
|
||||
'permission_callback' => array( $this, 'check_permissions' ),
|
||||
)
|
||||
);
|
||||
|
||||
// Image generation endpoints.
|
||||
register_rest_route(
|
||||
'wp-agentic-writer/v1',
|
||||
'/image-recommendations/(?P<post_id>\d+)',
|
||||
array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array( $this, 'handle_get_image_recommendations' ),
|
||||
'permission_callback' => array( $this, 'check_permissions' ),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
'wp-agentic-writer/v1',
|
||||
'/generate-image',
|
||||
array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array( $this, 'handle_generate_image' ),
|
||||
'permission_callback' => array( $this, 'check_permissions' ),
|
||||
)
|
||||
);
|
||||
|
||||
register_rest_route(
|
||||
'wp-agentic-writer/v1',
|
||||
'/commit-image',
|
||||
array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array( $this, 'handle_commit_image' ),
|
||||
'permission_callback' => array( $this, 'check_permissions' ),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -498,17 +562,39 @@ class WP_Agentic_Writer_Gutenberg_Sidebar {
|
||||
$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}";
|
||||
|
||||
$messages = $this->prepend_system_prompt( $messages, $system_prompt );
|
||||
|
||||
// Get OpenRouter provider.
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
// Get provider for this task type.
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type );
|
||||
|
||||
if ( $stream ) {
|
||||
$web_search_options = $this->get_web_search_options( $post_config );
|
||||
@@ -567,33 +653,55 @@ CRITICAL LANGUAGE REQUIREMENT:
|
||||
header( 'Cache-Control: no-cache' );
|
||||
header( 'X-Accel-Buffering: no' );
|
||||
|
||||
if ( ob_get_level() > 0 ) {
|
||||
// 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();
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type );
|
||||
$accumulated_content = '';
|
||||
$total_cost = 0;
|
||||
$chunks_emitted = 0;
|
||||
|
||||
$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 ) {
|
||||
function( $chunk, $is_complete, $full_content ) use ( &$accumulated_content, &$chunks_emitted ) {
|
||||
$accumulated_content = $full_content;
|
||||
if ( '' !== $chunk ) {
|
||||
$chunks_emitted++;
|
||||
echo "data: " . wp_json_encode(
|
||||
array(
|
||||
'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(
|
||||
array(
|
||||
'type' => 'conversational_stream',
|
||||
'content' => $accumulated_content,
|
||||
)
|
||||
) . "\n\n";
|
||||
flush();
|
||||
}
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
echo "data: " . wp_json_encode(
|
||||
array(
|
||||
@@ -1020,11 +1128,12 @@ CRITICAL LANGUAGE REQUIREMENT:
|
||||
}
|
||||
|
||||
// If fallback is provided and not empty, use it
|
||||
if ( ! empty( $fallback ) && 'auto' !== $fallback ) {
|
||||
return $fallback;
|
||||
if ( ! empty( $fallback ) && 'auto' !== strtolower( $fallback ) ) {
|
||||
return strtolower( $fallback );
|
||||
}
|
||||
|
||||
return 'english';
|
||||
// Default to 'auto' instead of 'english' to let AI detect from context
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1040,7 +1149,7 @@ CRITICAL LANGUAGE REQUIREMENT:
|
||||
|
||||
// If auto or empty, let AI detect from context
|
||||
if ( empty( $language ) || 'auto' === strtolower( $language ) ) {
|
||||
return "Write the {$context} in the most appropriate language based on the topic and context.";
|
||||
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
|
||||
@@ -1072,6 +1181,63 @@ CRITICAL LANGUAGE REQUIREMENT:
|
||||
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
|
||||
*/
|
||||
private function maybe_inject_brave_search( &$messages, $provider, $web_search_options ) {
|
||||
if ( empty( $web_search_options['web_search_enabled'] ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only inject if the provider doesn't natively support OpenRouter's web search routing plugins
|
||||
if ( $provider instanceof WP_Agentic_Writer_OpenRouter_Provider ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$last_query = '';
|
||||
foreach ( array_reverse( $messages ) as $msg ) {
|
||||
if ( 'user' === $msg['role'] ) {
|
||||
$last_query = (string) $msg['content'];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $last_query ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$brave_search = WP_Agentic_Writer_Brave_Search_API::get_instance();
|
||||
$results = $brave_search->search( $last_query, 3 );
|
||||
|
||||
if ( ! is_wp_error( $results ) && ! empty( $results ) ) {
|
||||
$context_markdown = $brave_search->format_results_for_llm( $results, $last_query );
|
||||
|
||||
$injection_message = array(
|
||||
'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, array( $injection_message ) );
|
||||
$injected = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $injected ) {
|
||||
array_unshift( $messages, $injection_message );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build web search option overrides.
|
||||
*
|
||||
@@ -1206,12 +1372,22 @@ CRITICAL LANGUAGE REQUIREMENT:
|
||||
return $this->stream_generate_plan( $topic, $context, $post_id, $auto_execute, $article_length, $clarification_answers, $effective_language, $post_config, $chat_history );
|
||||
}
|
||||
|
||||
// Get OpenRouter provider.
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
// Get provider for planning task.
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
|
||||
// Build prompt for plan generation.
|
||||
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
|
||||
$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.
|
||||
$system_prompt = "You are an Information Architect and SEO/GEO Strategist. Your task is to outline a high-information-density article based on the user's topic and context.
|
||||
|
||||
ANTI-ROBOT RULES:
|
||||
- Never use generic intros or 'throat-clearing' fluff.
|
||||
- Avoid academic, pompous, or 'expert' posturing.
|
||||
- Headings must provide direct value or ask specific questions the article will answer.
|
||||
|
||||
GEO/SEO STRATEGY:
|
||||
- Design the outline for Generative Engine Optimization (GEO): sections must flow logically to answer the user's core intent comprehensively.
|
||||
- Suggest strategic use of tables, bullet points, and Q&A formats where they maximize information density.
|
||||
- Incorporate secondary entities and related concepts naturally to show topical depth.
|
||||
|
||||
CRITICAL LANGUAGE REQUIREMENT:
|
||||
{$plan_language_instruction}
|
||||
@@ -1256,6 +1432,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar
|
||||
);
|
||||
|
||||
// Generate plan.
|
||||
$this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
|
||||
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
@@ -1350,7 +1527,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar
|
||||
);
|
||||
}
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
$memory_context = $this->get_post_memory_context( $post_id );
|
||||
|
||||
$system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction.
|
||||
@@ -1402,6 +1579,8 @@ Rules:
|
||||
),
|
||||
);
|
||||
|
||||
// Generate revised plan.
|
||||
$this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
|
||||
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.6 ), $web_search_options ), 'planning' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
@@ -1576,13 +1755,26 @@ Rules:
|
||||
}
|
||||
flush();
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
|
||||
$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 );
|
||||
$effective_language = $this->resolve_language_preference( $post_config, $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
|
||||
@@ -1656,8 +1848,22 @@ Rules:
|
||||
// Determine language instruction for plan generation
|
||||
$plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' );
|
||||
|
||||
$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.
|
||||
// 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}
|
||||
|
||||
@@ -1706,6 +1912,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar
|
||||
error_log( 'WP Agentic Writer: Calling OpenRouter API for planning. Topic: ' . substr( $topic, 0, 100 ) );
|
||||
error_log( 'WP Agentic Writer: Detected language: ' . $detected_language );
|
||||
|
||||
$this->maybe_inject_brave_search( $messages, $provider, $web_search_options );
|
||||
$response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' );
|
||||
|
||||
error_log( 'WP Agentic Writer: OpenRouter API response received' );
|
||||
@@ -2105,7 +2312,31 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati
|
||||
|
||||
// NOW parse the complete markdown content and send blocks
|
||||
if ( ! empty( $markdown_content ) ) {
|
||||
$markdown_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $markdown_content );
|
||||
// Extract image placeholders and generate IDs
|
||||
$image_placeholders = array();
|
||||
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[] = array(
|
||||
'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(
|
||||
@@ -2221,8 +2452,8 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati
|
||||
}
|
||||
}
|
||||
|
||||
// Get OpenRouter provider.
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
// Get provider for writing task.
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
|
||||
|
||||
$image_instruction = "IMAGE SUGGESTIONS:
|
||||
- Suggest where images would enhance understanding
|
||||
@@ -2282,7 +2513,18 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati
|
||||
}
|
||||
|
||||
// Build system prompt for article generation.
|
||||
$system_prompt = "You are an expert technical writer and blogger. Your task is to write engaging, clear, and well-structured content based on the provided article plan.
|
||||
$system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
|
||||
|
||||
ANTI-ROBOT RULES:
|
||||
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
|
||||
- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
|
||||
- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
|
||||
- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
|
||||
|
||||
GEO/SEO STRATEGY:
|
||||
- Answer the implicit user intent directly and immediately in the first paragraph.
|
||||
- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
|
||||
- Use bullet points or numbered lists where they make data easier to scan.
|
||||
|
||||
CRITICAL LANGUAGE REQUIREMENT:
|
||||
{$language_instruction}
|
||||
@@ -2291,22 +2533,17 @@ CRITICAL LANGUAGE REQUIREMENT:
|
||||
|
||||
Follow these guidelines:
|
||||
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
|
||||
- Use clear examples and analogies
|
||||
- For code blocks, use proper syntax highlighting
|
||||
- Keep paragraphs concise (2-3 sentences)
|
||||
- Use transitions between sections
|
||||
- Write for the specified difficulty level
|
||||
- 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.
|
||||
- Embed secondary keywords naturally as concepts, without forcing exact matches
|
||||
- For code blocks, use proper syntax highlighting (e.g., ```php)
|
||||
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
|
||||
- Write for the specified difficulty level
|
||||
{$seo_instruction}
|
||||
{$internal_links_instruction}
|
||||
|
||||
IMAGE SUGGESTIONS:
|
||||
- Suggest where images would enhance understanding
|
||||
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
|
||||
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
|
||||
- Good places for images: after introductions, before complex explanations, to show examples
|
||||
- Maximum 1-2 image suggestions per section
|
||||
- Suggest where images would enhance understanding (diagrams, screenshots)
|
||||
- Place image suggestions on their own line: [IMAGE: descriptive alt text]
|
||||
- Maximum 1 image per section
|
||||
{$image_instruction}";
|
||||
|
||||
// Generate content for each section.
|
||||
@@ -2435,7 +2672,7 @@ IMAGE SUGGESTIONS:
|
||||
}
|
||||
flush();
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
|
||||
$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 );
|
||||
@@ -2527,7 +2764,18 @@ IMAGE SUGGESTIONS:
|
||||
}
|
||||
}
|
||||
|
||||
$system_prompt = "You are an expert technical writer and blogger. Your task is to write engaging, clear, and well-structured content based on the provided article plan.
|
||||
$system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
|
||||
|
||||
ANTI-ROBOT RULES:
|
||||
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
|
||||
- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
|
||||
- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
|
||||
- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
|
||||
|
||||
GEO/SEO STRATEGY:
|
||||
- Answer the implicit user intent directly and immediately in the first paragraph.
|
||||
- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
|
||||
- Use bullet points or numbered lists where they make data easier to scan.
|
||||
|
||||
CRITICAL LANGUAGE REQUIREMENT:
|
||||
{$language_instruction}
|
||||
@@ -2535,22 +2783,17 @@ CRITICAL LANGUAGE REQUIREMENT:
|
||||
|
||||
Follow these guidelines:
|
||||
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
|
||||
- Use clear examples and analogies
|
||||
- For code blocks, use proper syntax highlighting
|
||||
- Keep paragraphs concise (2-3 sentences)
|
||||
- Use transitions between sections
|
||||
- Write for the specified difficulty level
|
||||
- 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.
|
||||
- Embed secondary keywords naturally as concepts, without forcing exact matches
|
||||
- For code blocks, use proper syntax highlighting (e.g., ```php)
|
||||
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
|
||||
- Write for the specified difficulty level
|
||||
{$seo_instruction}
|
||||
{$internal_links_instruction}
|
||||
|
||||
IMAGE SUGGESTIONS:
|
||||
- Suggest where images would enhance understanding
|
||||
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
|
||||
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
|
||||
- Good places for images: after introductions, before complex explanations, to show examples
|
||||
- Maximum 1-2 image suggestions per section
|
||||
- Suggest where images would enhance understanding (diagrams, screenshots)
|
||||
- Place image suggestions on their own line: [IMAGE: descriptive alt text]
|
||||
- Maximum 1 image per section
|
||||
{$image_instruction}";
|
||||
|
||||
$section_index = 0;
|
||||
@@ -2795,8 +3038,8 @@ IMAGE SUGGESTIONS:
|
||||
);
|
||||
}
|
||||
|
||||
// Get OpenRouter provider.
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
// Get provider for writing task.
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
|
||||
|
||||
$messages = array(
|
||||
array(
|
||||
@@ -2944,7 +3187,7 @@ IMAGE SUGGESTIONS:
|
||||
);
|
||||
}
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
|
||||
|
||||
// Get settings.
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
@@ -2960,15 +3203,21 @@ IMAGE SUGGESTIONS:
|
||||
'pov',
|
||||
);
|
||||
|
||||
// If quiz is disabled, always return clear.
|
||||
// If quiz is disabled, skip AI questions but still add MANDATORY config questions
|
||||
if ( ! $enabled ) {
|
||||
$result = array(
|
||||
'is_clear' => true,
|
||||
'confidence' => 1.0,
|
||||
'questions' => array(),
|
||||
);
|
||||
// MANDATORY: Always add config questions (language, focus keyword)
|
||||
$result['questions'] = $this->append_config_questions( $result['questions'], $post_config );
|
||||
if ( ! empty( $result['questions'] ) ) {
|
||||
$result['is_clear'] = false; // Force quiz for config questions
|
||||
}
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'result' => array(
|
||||
'is_clear' => true,
|
||||
'confidence' => 1.0,
|
||||
'questions' => array(),
|
||||
),
|
||||
'result' => $result,
|
||||
'cost' => 0,
|
||||
),
|
||||
200
|
||||
@@ -3079,9 +3328,11 @@ QUESTION TYPES:
|
||||
|
||||
CONFIDENCE CALCULATION:
|
||||
- Start at 100% (1.0)
|
||||
- Subtract 20% for each missing HIGH-PRIORITY category (topic_scope, target_platform, specific_focus)
|
||||
- Subtract 15% for each missing HIGH-PRIORITY category (topic_scope, target_platform, specific_focus)
|
||||
- Subtract 10% for each missing SECONDARY category (target_outcome, target_audience, content_depth)
|
||||
- CRITICAL: If chat history exists with detailed discussion, ADD 20% confidence bonus (user already provided context)
|
||||
- If confidence < {$threshold}, generate questions starting with HIGH-PRIORITY
|
||||
- MANDATORY: Even if confidence >= threshold, ALWAYS ask at least 1-2 system config questions (language, SEO settings)
|
||||
|
||||
QUESTION GENERATION STRATEGY:
|
||||
1. Always detect user language first and match it
|
||||
@@ -3118,6 +3369,11 @@ No markdown, no explanation - just JSON.";
|
||||
// Log error and use default questions instead of failing.
|
||||
error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() );
|
||||
$result = $this->get_default_clarification_questions( $topic );
|
||||
// MANDATORY: Always add config questions
|
||||
$result['questions'] = $this->append_config_questions( $result['questions'] ?? array(), $post_config );
|
||||
if ( ! empty( $result['questions'] ) ) {
|
||||
$result['is_clear'] = false;
|
||||
}
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'result' => $result,
|
||||
@@ -3135,6 +3391,11 @@ No markdown, no explanation - just JSON.";
|
||||
// Log parse error and use default questions instead of failing.
|
||||
error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' );
|
||||
$result = $this->get_default_clarification_questions( $topic );
|
||||
// MANDATORY: Always add config questions
|
||||
$result['questions'] = $this->append_config_questions( $result['questions'] ?? array(), $post_config );
|
||||
if ( ! empty( $result['questions'] ) ) {
|
||||
$result['is_clear'] = false;
|
||||
}
|
||||
return new WP_REST_Response(
|
||||
array(
|
||||
'result' => $result,
|
||||
@@ -3156,15 +3417,15 @@ No markdown, no explanation - just JSON.";
|
||||
$response['cost'] ?? 0
|
||||
);
|
||||
|
||||
// Always add configuration questions, even if no clarity questions
|
||||
// MANDATORY: Always add configuration questions
|
||||
if ( ! isset( $result['questions'] ) || ! is_array( $result['questions'] ) ) {
|
||||
$result['questions'] = array();
|
||||
}
|
||||
$result['questions'] = $this->append_config_questions( $result['questions'], $post_config );
|
||||
|
||||
// If only config questions exist and clarity is clear, still show config
|
||||
if ( empty( $result['questions'] ) === false && $result['is_clear'] === true ) {
|
||||
$result['is_clear'] = false; // Force quiz to show for config questions
|
||||
// CRITICAL: Always show quiz if config questions exist (system questions are MANDATORY)
|
||||
if ( ! empty( $result['questions'] ) ) {
|
||||
$result['is_clear'] = false; // Force quiz to show - config questions are mandatory
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
@@ -3312,7 +3573,7 @@ No markdown, no explanation - just JSON.";
|
||||
return $this->stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config );
|
||||
}
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' );
|
||||
|
||||
// Build context from article structure.
|
||||
$context_str = "\n\nArticle Context:\n";
|
||||
@@ -3450,7 +3711,7 @@ Keep the same block type (paragraph, heading, list, etc.).";
|
||||
}
|
||||
flush();
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' );
|
||||
$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 );
|
||||
@@ -3473,10 +3734,16 @@ Keep the same block type (paragraph, heading, list, etc.).";
|
||||
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
|
||||
}
|
||||
|
||||
$system_prompt = "You are an expert content refiner. Your task is to rewrite and improve content based on the user's request.
|
||||
$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}
|
||||
@@ -4057,7 +4324,7 @@ No markdown, no explanation - just JSON.";
|
||||
);
|
||||
}
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' );
|
||||
$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 );
|
||||
@@ -4225,7 +4492,12 @@ Blocks:
|
||||
$context_str .= "Next section: " . $article_context['nextBlock']['heading'] . "\n";
|
||||
}
|
||||
|
||||
$system_prompt = "You are an expert content refiner. Your task is to rewrite and improve content based on the user's request.
|
||||
$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}
|
||||
@@ -5026,7 +5298,7 @@ Output format:
|
||||
}
|
||||
|
||||
$language_instruction = $this->build_language_instruction( $effective_language, 'meta description' );
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
|
||||
|
||||
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
|
||||
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
|
||||
@@ -5138,7 +5410,7 @@ Output format:
|
||||
}
|
||||
}
|
||||
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
|
||||
|
||||
$prompt = "Generate a compelling meta description for SEO. Requirements:\n";
|
||||
$prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n";
|
||||
@@ -5303,8 +5575,8 @@ PREFERENCES: [any specific requirements]
|
||||
Conversation:
|
||||
{$history_text}";
|
||||
|
||||
// Call AI with cheap model
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
// Call AI with clarity model for language detection
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
@@ -5385,8 +5657,8 @@ User's message: \"{$last_message}\"
|
||||
|
||||
Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
|
||||
|
||||
// Call AI with cheap model
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
// Call AI with clarity model for intent detection
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
@@ -5429,4 +5701,79 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
|
||||
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' );
|
||||
|
||||
$image_manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
||||
$images = $image_manager->get_image_recommendations( $post_id );
|
||||
|
||||
return new WP_REST_Response(
|
||||
array( 'images' => $images ),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generate image request.
|
||||
*
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_generate_image( $request ) {
|
||||
$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;
|
||||
|
||||
$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(
|
||||
array( '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' );
|
||||
|
||||
$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 );
|
||||
}
|
||||
}
|
||||
|
||||
689
includes/class-image-manager.php
Normal file
689
includes/class-image-manager.php
Normal file
@@ -0,0 +1,689 @@
|
||||
<?php
|
||||
/**
|
||||
* Image Manager Class
|
||||
*
|
||||
* Handles image generation, variant management, and WordPress Media integration.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image Manager class.
|
||||
*/
|
||||
class WP_Agentic_Writer_Image_Manager {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Image_Manager
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return WP_Agentic_Writer_Image_Manager
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
// Private constructor for singleton.
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database tables on plugin activation.
|
||||
*/
|
||||
public function create_tables() {
|
||||
global $wpdb;
|
||||
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
// Table 1: wp_wpaw_images
|
||||
$table_images = $wpdb->prefix . 'wpaw_images';
|
||||
$sql_images = "CREATE TABLE IF NOT EXISTS `{$table_images}` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`post_id` bigint(20) NOT NULL,
|
||||
`agent_image_id` varchar(50) NOT NULL,
|
||||
`placement` varchar(100) DEFAULT NULL,
|
||||
`section_title` varchar(255) DEFAULT NULL,
|
||||
`prompt_initial` text NOT NULL,
|
||||
`alt_text_initial` text DEFAULT NULL,
|
||||
`prompt_edited` text DEFAULT NULL,
|
||||
`alt_text_edited` text DEFAULT NULL,
|
||||
`attachment_id` bigint(20) DEFAULT NULL,
|
||||
`status` varchar(30) DEFAULT 'pending',
|
||||
`cost_estimate` decimal(10, 4) DEFAULT NULL,
|
||||
`cost_actual` decimal(10, 4) DEFAULT NULL,
|
||||
`image_model` varchar(100) DEFAULT NULL,
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_post` (`post_id`),
|
||||
KEY `idx_agent_image_id` (`post_id`, `agent_image_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created` (`created_at`)
|
||||
) {$charset_collate};";
|
||||
|
||||
// Table 2: wp_wpaw_images_variants
|
||||
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
|
||||
$sql_variants = "CREATE TABLE IF NOT EXISTS `{$table_variants}` (
|
||||
`id` bigint(20) NOT NULL AUTO_INCREMENT,
|
||||
`agentic_image_id` bigint(20) NOT NULL,
|
||||
`post_id` bigint(20) NOT NULL,
|
||||
`agent_image_id` varchar(50) NOT NULL,
|
||||
`variant_number` int(11) DEFAULT 1,
|
||||
`temp_file_path` varchar(500) NOT NULL,
|
||||
`temp_file_url` varchar(500) NOT NULL,
|
||||
`file_size` int(11) DEFAULT NULL,
|
||||
`prompt_used` text DEFAULT NULL,
|
||||
`image_model_used` varchar(100) DEFAULT NULL,
|
||||
`generation_time` int(11) DEFAULT NULL,
|
||||
`cost` decimal(10, 4) DEFAULT NULL,
|
||||
`is_selected` tinyint(1) DEFAULT 0,
|
||||
`selected_at` datetime DEFAULT NULL,
|
||||
`status` varchar(30) DEFAULT 'temp',
|
||||
`created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`deleted_at` datetime DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_agentic_image` (`agentic_image_id`),
|
||||
KEY `idx_post` (`post_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_created` (`created_at`)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql_images );
|
||||
dbDelta( $sql_variants );
|
||||
|
||||
// Create temp directory.
|
||||
$this->create_temp_directory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create temp directory for image storage.
|
||||
*/
|
||||
private function create_temp_directory() {
|
||||
$upload_dir = wp_upload_dir();
|
||||
$temp_dir = $upload_dir['basedir'] . '/wpaw';
|
||||
|
||||
if ( ! file_exists( $temp_dir ) ) {
|
||||
wp_mkdir_p( $temp_dir );
|
||||
|
||||
// Add .htaccess to prevent direct access.
|
||||
$htaccess = $temp_dir . '/.htaccess';
|
||||
if ( ! file_exists( $htaccess ) ) {
|
||||
file_put_contents( $htaccess, "Options -Indexes\n" );
|
||||
}
|
||||
|
||||
// Add index.php for security.
|
||||
$index = $temp_dir . '/index.php';
|
||||
if ( ! file_exists( $index ) ) {
|
||||
file_put_contents( $index, "<?php // Silence is golden\n" );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze article for optimal image placement.
|
||||
*
|
||||
* @param string $article_markdown Article content in markdown.
|
||||
* @param int $post_id Post ID.
|
||||
* @return array|WP_Error Placement data or error.
|
||||
*/
|
||||
public function analyze_article_for_images( $article_markdown, $post_id ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$writing_model = $settings['writing_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
|
||||
$system_prompt = "You are an expert content strategist analyzing articles for optimal image placement.
|
||||
|
||||
Your task: Identify 2-3 strategic locations where images would enhance understanding and engagement.
|
||||
|
||||
RULES:
|
||||
1. Prioritize placement after introduction (hero image)
|
||||
2. Consider complex sections that need visual aids
|
||||
3. Look for opportunities before conclusions
|
||||
4. Maximum 3 images per article
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
\"recommended_image_count\": 3,
|
||||
\"image_placement_points\": [
|
||||
{
|
||||
\"agent_image_id\": \"img_hero_1\",
|
||||
\"placement\": \"after_introduction\",
|
||||
\"section_title\": \"Introduction\",
|
||||
\"image_type\": \"hero_dashboard\",
|
||||
\"reasoning\": \"Sets visual tone for article\"
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => "Analyze this article for image placement:\n\n" . $article_markdown,
|
||||
),
|
||||
);
|
||||
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
$response = $provider->chat( $messages, array( 'temperature' => 0.3 ), 'planning' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Extract JSON from response.
|
||||
$json_match = array();
|
||||
if ( preg_match( '/\{[\s\S]*\}/m', $response['content'], $json_match ) ) {
|
||||
$placement_data = json_decode( $json_match[0], true );
|
||||
if ( JSON_ERROR_NONE === json_last_error() ) {
|
||||
return $placement_data;
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_Error( 'parse_error', 'Failed to parse placement analysis' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image prompts optimized for specific image model.
|
||||
*
|
||||
* @param string $article_markdown Article content.
|
||||
* @param array $placement_data Placement analysis.
|
||||
* @param int $post_id Post ID.
|
||||
* @return array|WP_Error Image specifications or error.
|
||||
*/
|
||||
public function generate_image_prompts( $article_markdown, $placement_data, $post_id ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$writing_model = $settings['writing_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
|
||||
// Get model-specific prompt guidance.
|
||||
$prompt_guidance = $this->get_prompt_guidance_for_model( $image_model );
|
||||
|
||||
$system_prompt = "You are an Image Prompt Engineer specializing in {$prompt_guidance['model_name']}.
|
||||
|
||||
TARGET MODEL: {$prompt_guidance['model_name']}
|
||||
PROMPT LENGTH: {$prompt_guidance['prompt_length']}
|
||||
COMPLEXITY: {$prompt_guidance['complexity']}
|
||||
|
||||
{$prompt_guidance['guidance']}
|
||||
|
||||
TEMPLATE: {$prompt_guidance['template']}
|
||||
|
||||
Generate precise, cost-efficient prompts that exploit this model's strengths.
|
||||
|
||||
Return JSON:
|
||||
{
|
||||
\"images\": [
|
||||
{
|
||||
\"agent_image_id\": \"img_hero_1\",
|
||||
\"placement\": \"after_introduction\",
|
||||
\"section_title\": \"Introduction\",
|
||||
\"prompt\": \"[Model-optimized prompt]\",
|
||||
\"alt\": \"Descriptive alt text\",
|
||||
\"image_model\": \"{$image_model}\"
|
||||
}
|
||||
]
|
||||
}";
|
||||
|
||||
$user_input = wp_json_encode(
|
||||
array(
|
||||
'article' => $article_markdown,
|
||||
'placement_points' => $placement_data['image_placement_points'],
|
||||
'image_count' => $placement_data['recommended_image_count'],
|
||||
'target_image_model' => $image_model,
|
||||
)
|
||||
);
|
||||
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => "Generate image prompts:\n\n" . $user_input,
|
||||
),
|
||||
);
|
||||
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Extract JSON.
|
||||
$json_match = array();
|
||||
if ( preg_match( '/\{[\s\S]*\}/m', $response['content'], $json_match ) ) {
|
||||
$image_specs = json_decode( $json_match[0], true );
|
||||
if ( JSON_ERROR_NONE === json_last_error() ) {
|
||||
// Save to database.
|
||||
$this->save_image_recommendations( $post_id, $image_specs['images'] );
|
||||
return $image_specs;
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_Error( 'parse_error', 'Failed to parse image prompts' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get prompt guidance for specific image model.
|
||||
*
|
||||
* @param string $image_model Image model ID.
|
||||
* @return array Model configuration.
|
||||
*/
|
||||
private function get_prompt_guidance_for_model( $image_model ) {
|
||||
$model_configs = array(
|
||||
'black-forest-labs/flux.2-klein' => array(
|
||||
'model_name' => 'FLUX.2 [klein]',
|
||||
'prompt_length' => '1-2 sentences',
|
||||
'complexity' => 'simple',
|
||||
'guidance' => 'Keep prompts short and simple. Focus on main subject, key details, and style. Avoid complex scenes or technical specifications.',
|
||||
'template' => 'Subject, key elements, style, color palette',
|
||||
),
|
||||
'sourceful/riverflow-v2-max' => array(
|
||||
'model_name' => 'Riverflow V2 Max',
|
||||
'prompt_length' => '3-4 sentences',
|
||||
'complexity' => 'medium-detailed',
|
||||
'guidance' => 'Include context, environment details, lighting style, and photographic specifications. Model excels at photorealism.',
|
||||
'template' => 'Subject + context, environment details, lighting style, photography style, technical specs',
|
||||
),
|
||||
'black-forest-labs/flux.2-max' => array(
|
||||
'model_name' => 'FLUX.2 [max]',
|
||||
'prompt_length' => '4-6 sentences',
|
||||
'complexity' => 'very-detailed-technical',
|
||||
'guidance' => 'Use detailed technical vocabulary. Include exact materials, color codes (HEX), spatial relationships, and specifications.',
|
||||
'template' => 'Technical foundation, main subject + action, environment, lighting + mood, style + aesthetics, technical specifications',
|
||||
),
|
||||
);
|
||||
|
||||
// Default to Riverflow if model not found.
|
||||
return $model_configs[ $image_model ] ?? $model_configs['sourceful/riverflow-v2-max'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Save image recommendations to database.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $images Image specifications.
|
||||
*/
|
||||
private function save_image_recommendations( $post_id, $images ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpaw_images';
|
||||
|
||||
foreach ( $images as $image_spec ) {
|
||||
$wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'agent_image_id' => $image_spec['agent_image_id'],
|
||||
'placement' => $image_spec['placement'],
|
||||
'section_title' => $image_spec['section_title'],
|
||||
'prompt_initial' => $image_spec['prompt'],
|
||||
'alt_text_initial' => $image_spec['alt'],
|
||||
'image_model' => $image_spec['image_model'],
|
||||
'status' => 'pending',
|
||||
),
|
||||
array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save single image recommendation to database.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $agent_image_id Unique image identifier.
|
||||
* @param string $placement Placement location.
|
||||
* @param string $section_title Section title.
|
||||
* @param string $prompt Image prompt/description.
|
||||
* @param string $alt_text Alt text for image.
|
||||
* @return int|false Insert ID or false on failure.
|
||||
*/
|
||||
public function save_image_recommendation( $post_id, $agent_image_id, $placement, $section_title, $prompt, $alt_text ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpaw_images';
|
||||
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
|
||||
$result = $wpdb->insert(
|
||||
$table,
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'agent_image_id' => $agent_image_id,
|
||||
'placement' => $placement,
|
||||
'section_title' => $section_title,
|
||||
'prompt_initial' => $prompt,
|
||||
'alt_text_initial' => $alt_text,
|
||||
'image_model' => $image_model,
|
||||
'status' => 'pending',
|
||||
),
|
||||
array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
if ( $result ) {
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image recommendations for a post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array Image recommendations.
|
||||
*/
|
||||
public function get_image_recommendations( $post_id ) {
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpaw_images';
|
||||
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at ASC",
|
||||
$post_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image variants.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $agent_image_id Agent image ID.
|
||||
* @param string $prompt Image prompt.
|
||||
* @param int $variant_count Number of variants to generate.
|
||||
* @return array|WP_Error Generated variants or error.
|
||||
*/
|
||||
public function generate_image_variants( $post_id, $agent_image_id, $prompt, $variant_count = 2 ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'image' );
|
||||
|
||||
$variants = array();
|
||||
|
||||
for ( $i = 1; $i <= $variant_count; $i++ ) {
|
||||
$result = $provider->generate_image(
|
||||
$prompt,
|
||||
$image_model,
|
||||
array(
|
||||
'size' => '1024x576',
|
||||
'quality' => 'hd',
|
||||
'n' => 1,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
// Download image to temp directory.
|
||||
$temp_file = $this->download_temp_image( $post_id, $agent_image_id, $result['url'], $i );
|
||||
|
||||
if ( is_wp_error( $temp_file ) ) {
|
||||
return $temp_file;
|
||||
}
|
||||
|
||||
// Save variant to database.
|
||||
$variant_id = $this->save_variant(
|
||||
$post_id,
|
||||
$agent_image_id,
|
||||
$i,
|
||||
$temp_file,
|
||||
$prompt,
|
||||
$image_model,
|
||||
$result
|
||||
);
|
||||
|
||||
$variants[] = array(
|
||||
'id' => $variant_id,
|
||||
'variant_number' => $i,
|
||||
'temp_file_url' => $temp_file['url'],
|
||||
'cost' => $result['cost'],
|
||||
'generation_time' => $result['generation_time'],
|
||||
'image_model_used' => $image_model,
|
||||
);
|
||||
}
|
||||
|
||||
return $variants;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download image to temp directory.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $agent_image_id Agent image ID.
|
||||
* @param string $image_url Image URL.
|
||||
* @param int $variant_number Variant number.
|
||||
* @return array|WP_Error File info or error.
|
||||
*/
|
||||
private function download_temp_image( $post_id, $agent_image_id, $image_url, $variant_number ) {
|
||||
$upload_dir = wp_upload_dir();
|
||||
$temp_dir = $upload_dir['basedir'] . '/wpaw/' . $post_id;
|
||||
|
||||
if ( ! file_exists( $temp_dir ) ) {
|
||||
wp_mkdir_p( $temp_dir );
|
||||
}
|
||||
|
||||
// Download image.
|
||||
$response = wp_remote_get( $image_url, array( 'timeout' => 30 ) );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$image_data = wp_remote_retrieve_body( $response );
|
||||
|
||||
// Determine file extension from content type.
|
||||
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
|
||||
$extension = 'jpg';
|
||||
if ( strpos( $content_type, 'png' ) !== false ) {
|
||||
$extension = 'png';
|
||||
}
|
||||
|
||||
$filename = sprintf(
|
||||
'%s_variant_%d_%d.%s',
|
||||
$agent_image_id,
|
||||
$variant_number,
|
||||
time(),
|
||||
$extension
|
||||
);
|
||||
|
||||
$file_path = $temp_dir . '/' . $filename;
|
||||
file_put_contents( $file_path, $image_data );
|
||||
|
||||
$file_url = $upload_dir['baseurl'] . '/wpaw/' . $post_id . '/' . $filename;
|
||||
|
||||
return array(
|
||||
'path' => $file_path,
|
||||
'url' => $file_url,
|
||||
'size' => filesize( $file_path ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save variant to database.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $agent_image_id Agent image ID.
|
||||
* @param int $variant_number Variant number.
|
||||
* @param array $temp_file Temp file info.
|
||||
* @param string $prompt Prompt used.
|
||||
* @param string $image_model Image model used.
|
||||
* @param array $generation_result Generation result.
|
||||
* @return int Variant ID.
|
||||
*/
|
||||
private function save_variant( $post_id, $agent_image_id, $variant_number, $temp_file, $prompt, $image_model, $generation_result ) {
|
||||
global $wpdb;
|
||||
|
||||
// Get agentic_image_id from wp_wpaw_images.
|
||||
$table_images = $wpdb->prefix . 'wpaw_images';
|
||||
$agentic_image_id = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT id FROM {$table_images} WHERE post_id = %d AND agent_image_id = %s",
|
||||
$post_id,
|
||||
$agent_image_id
|
||||
)
|
||||
);
|
||||
|
||||
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
|
||||
|
||||
$wpdb->insert(
|
||||
$table_variants,
|
||||
array(
|
||||
'agentic_image_id' => $agentic_image_id,
|
||||
'post_id' => $post_id,
|
||||
'agent_image_id' => $agent_image_id,
|
||||
'variant_number' => $variant_number,
|
||||
'temp_file_path' => $temp_file['path'],
|
||||
'temp_file_url' => $temp_file['url'],
|
||||
'file_size' => $temp_file['size'],
|
||||
'prompt_used' => $prompt,
|
||||
'image_model_used' => $image_model,
|
||||
'generation_time' => $generation_result['generation_time'],
|
||||
'cost' => $generation_result['cost'],
|
||||
'status' => 'temp',
|
||||
),
|
||||
array( '%d', '%d', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%d', '%f', '%s' )
|
||||
);
|
||||
|
||||
return $wpdb->insert_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit image variant to WordPress Media Library.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $agent_image_id Agent image ID.
|
||||
* @param int $variant_id Variant ID.
|
||||
* @param string $alt_text Alt text.
|
||||
* @return array|WP_Error Attachment info or error.
|
||||
*/
|
||||
public function commit_image_variant( $post_id, $agent_image_id, $variant_id, $alt_text ) {
|
||||
global $wpdb;
|
||||
|
||||
// Get variant info.
|
||||
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
|
||||
$variant = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table_variants} WHERE id = %d",
|
||||
$variant_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $variant ) {
|
||||
return new WP_Error( 'variant_not_found', 'Variant not found' );
|
||||
}
|
||||
|
||||
// Upload to Media Library.
|
||||
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||
|
||||
$file_array = array(
|
||||
'name' => basename( $variant['temp_file_path'] ),
|
||||
'tmp_name' => $variant['temp_file_path'],
|
||||
);
|
||||
|
||||
$attachment_id = media_handle_sideload( $file_array, $post_id );
|
||||
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
// Set alt text.
|
||||
update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt_text ) );
|
||||
|
||||
// Update wp_wpaw_images table.
|
||||
$table_images = $wpdb->prefix . 'wpaw_images';
|
||||
$wpdb->update(
|
||||
$table_images,
|
||||
array(
|
||||
'attachment_id' => $attachment_id,
|
||||
'status' => 'committed',
|
||||
),
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'agent_image_id' => $agent_image_id,
|
||||
),
|
||||
array( '%d', '%s' ),
|
||||
array( '%d', '%s' )
|
||||
);
|
||||
|
||||
// Mark variant as selected.
|
||||
$wpdb->update(
|
||||
$table_variants,
|
||||
array(
|
||||
'is_selected' => 1,
|
||||
'selected_at' => current_time( 'mysql' ),
|
||||
'status' => 'selected',
|
||||
),
|
||||
array( 'id' => $variant_id ),
|
||||
array( '%d', '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
|
||||
$attachment_url = wp_get_attachment_url( $attachment_id );
|
||||
|
||||
return array(
|
||||
'attachment_id' => $attachment_id,
|
||||
'attachment_url' => $attachment_url,
|
||||
'alt' => $alt_text,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old temp images (7+ days old).
|
||||
*/
|
||||
public function cleanup_old_temp_images() {
|
||||
global $wpdb;
|
||||
|
||||
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
|
||||
|
||||
// Get temp images older than 7 days.
|
||||
$old_variants = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table_variants}
|
||||
WHERE status = 'temp'
|
||||
AND created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
|
||||
7
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
foreach ( $old_variants as $variant ) {
|
||||
// Delete file.
|
||||
if ( file_exists( $variant['temp_file_path'] ) ) {
|
||||
unlink( $variant['temp_file_path'] );
|
||||
}
|
||||
|
||||
// Update status.
|
||||
$wpdb->update(
|
||||
$table_variants,
|
||||
array(
|
||||
'status' => 'auto_deleted',
|
||||
'deleted_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'id' => $variant['id'] ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%d' )
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,13 +30,14 @@ class WP_Agentic_Writer_Keyword_Suggester {
|
||||
* @return array|WP_Error Array with focus_keyword, secondary_keywords, reasoning, and cost.
|
||||
*/
|
||||
public static function suggest_keywords( $title, $sections, $language = 'english', $post_id = 0 ) {
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
|
||||
|
||||
// Build outline text from sections
|
||||
$outline_text = '';
|
||||
if ( is_array( $sections ) ) {
|
||||
foreach ( $sections as $section ) {
|
||||
$section_title = $section['title'] ?? '';
|
||||
// Support both 'heading' (new format) and 'title' (legacy)
|
||||
$section_title = $section['heading'] ?? $section['title'] ?? '';
|
||||
if ( ! empty( $section_title ) ) {
|
||||
$outline_text .= "- {$section_title}\n";
|
||||
}
|
||||
|
||||
416
includes/class-local-backend-provider.php
Normal file
416
includes/class-local-backend-provider.php
Normal file
@@ -0,0 +1,416 @@
|
||||
<?php
|
||||
/**
|
||||
* Local Backend Provider
|
||||
*
|
||||
* Connects to user's local Claude CLI proxy for AI inference
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_Provider_Interface {
|
||||
|
||||
/**
|
||||
* Local backend base URL
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $base_url = '';
|
||||
|
||||
/**
|
||||
* API key (dummy for local backend)
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $api_key = 'dummy';
|
||||
|
||||
/**
|
||||
* Model identifier
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $model = 'claude-local';
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
public function __construct() {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$this->base_url = $settings['local_backend_url'] ?? '';
|
||||
$this->api_key = $settings['local_backend_key'] ?? 'dummy';
|
||||
$this->model = $settings['local_backend_model'] ?? 'claude-local';
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-streaming chat completion
|
||||
*
|
||||
* @param array $messages Array of message objects.
|
||||
* @param array $options Optional parameters.
|
||||
* @param string $type Task type.
|
||||
* @return array|WP_Error Response with content, model, tokens, cost.
|
||||
*/
|
||||
public function chat( $messages, $options = array(), $type = 'planning' ) {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return new WP_Error(
|
||||
'not_configured',
|
||||
__( 'Local Backend URL not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$start_time = microtime( true );
|
||||
|
||||
$response = wp_remote_post(
|
||||
$this->base_url . '/v1/messages',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
),
|
||||
'body' => wp_json_encode(
|
||||
array(
|
||||
'messages' => $messages,
|
||||
)
|
||||
),
|
||||
'timeout' => 120, // Long timeout for local processing
|
||||
'sslverify' => false, // Local network
|
||||
)
|
||||
);
|
||||
|
||||
$generation_time = microtime( true ) - $start_time;
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return new WP_Error(
|
||||
'connection_failed',
|
||||
sprintf(
|
||||
/* translators: %s: error message */
|
||||
__( 'Local Backend connection failed: %s', 'wp-agentic-writer' ),
|
||||
$response->get_error_message()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
return new WP_Error(
|
||||
'api_error',
|
||||
sprintf(
|
||||
/* translators: %1$d: HTTP status code, %2$s: response body */
|
||||
__( 'Local Backend error (%1$d): %2$s', 'wp-agentic-writer' ),
|
||||
$code,
|
||||
$body
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
if ( ! isset( $body['choices'][0]['message']['content'] ) ) {
|
||||
return new WP_Error(
|
||||
'invalid_response',
|
||||
__( 'Invalid response format from Local Backend', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$content = $body['choices'][0]['message']['content'];
|
||||
|
||||
return array(
|
||||
'content' => $content,
|
||||
'model' => $this->model,
|
||||
'input_tokens' => 0, // Local backend doesn't track tokens
|
||||
'output_tokens' => 0,
|
||||
'total_tokens' => 0,
|
||||
'cost' => 0, // Free for local backend
|
||||
'generation_time' => $generation_time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat completion (not supported yet)
|
||||
*
|
||||
* @param array $messages Array of message objects.
|
||||
* @param array $options Optional parameters.
|
||||
* @param string $type Task type.
|
||||
* @param callable $callback Function to call with each chunk.
|
||||
* @return array|WP_Error Response or error.
|
||||
*/
|
||||
public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return new WP_Error(
|
||||
'not_configured',
|
||||
__( 'Local Backend URL not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$body = array(
|
||||
'messages' => $messages,
|
||||
'stream' => true,
|
||||
);
|
||||
|
||||
$accumulated_content = '';
|
||||
$accumulated_usage = array();
|
||||
$buffer = '';
|
||||
|
||||
$accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, $callback ) {
|
||||
if ( ! $is_complete && ! empty( $chunk ) ) {
|
||||
$accumulated_content .= $chunk;
|
||||
}
|
||||
|
||||
if ( $callback ) {
|
||||
call_user_func( $callback, $chunk, $is_complete, $accumulated_content );
|
||||
}
|
||||
};
|
||||
|
||||
$ch = curl_init( $this->base_url . '/v1/messages' );
|
||||
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_SSL_VERIFYPEER => false,
|
||||
CURLOPT_SSL_VERIFYHOST => false,
|
||||
CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) {
|
||||
$buffer .= $data;
|
||||
|
||||
while ( true ) {
|
||||
$newline_pos = strpos( $buffer, "\n" );
|
||||
if ( false === $newline_pos ) {
|
||||
break;
|
||||
}
|
||||
|
||||
$line = substr( $buffer, 0, $newline_pos );
|
||||
$buffer = substr( $buffer, $newline_pos + 1 );
|
||||
|
||||
$line = trim( $line );
|
||||
if ( empty( $line ) || ! str_starts_with( $line, 'data: ' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$json_str = substr( $line, 6 );
|
||||
|
||||
if ( '[DONE]' === $json_str || '"[DONE]"' === $json_str ) {
|
||||
call_user_func( $accumulating_callback, '', true );
|
||||
return strlen( $data );
|
||||
}
|
||||
|
||||
$chunk = json_decode( $json_str, true );
|
||||
if ( isset( $chunk['choices'][0]['delta']['content'] ) && is_string( $chunk['choices'][0]['delta']['content'] ) ) {
|
||||
$content = $chunk['choices'][0]['delta']['content'];
|
||||
call_user_func( $accumulating_callback, $content, false );
|
||||
}
|
||||
// Also support Anthropic format if proxy uses it
|
||||
if ( isset( $chunk['type'] ) && 'content_block_delta' === $chunk['type'] && isset( $chunk['delta']['text'] ) ) {
|
||||
$content = $chunk['delta']['text'];
|
||||
call_user_func( $accumulating_callback, $content, false );
|
||||
}
|
||||
|
||||
if ( isset( $chunk['usage'] ) ) {
|
||||
$accumulated_usage = $chunk['usage'];
|
||||
}
|
||||
}
|
||||
|
||||
return strlen( $data );
|
||||
},
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->api_key,
|
||||
),
|
||||
CURLOPT_POSTFIELDS => wp_json_encode( $body ),
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
) );
|
||||
|
||||
$start_time = microtime( true );
|
||||
$result = curl_exec( $ch );
|
||||
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$curl_error = curl_error( $ch );
|
||||
curl_close( $ch );
|
||||
|
||||
// Debug logging
|
||||
error_log( 'WPAW Local Backend chat_stream: HTTP=' . $http_code . ', curl_result=' . ( $result ? 'true' : 'false' ) . ', curl_error=' . $curl_error . ', accumulated_content_len=' . strlen( $accumulated_content ) . ', buffer_len=' . strlen( $buffer ) );
|
||||
|
||||
if ( false === $result && ! empty( $curl_error ) ) {
|
||||
return new WP_Error( 'curl_error', 'cURL error: ' . $curl_error );
|
||||
}
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
return new WP_Error( 'api_error', sprintf( 'API error (%d): %s', $http_code, $buffer ) );
|
||||
}
|
||||
|
||||
// FALLBACK: If no SSE chunks were parsed, the proxy likely returned a plain JSON response.
|
||||
// Try to parse the leftover buffer as a standard OpenAI/Anthropic JSON response.
|
||||
if ( empty( $accumulated_content ) && ! empty( $buffer ) ) {
|
||||
error_log( 'WPAW Local Backend: No SSE chunks parsed. Attempting raw JSON fallback. Buffer preview: ' . substr( $buffer, 0, 500 ) );
|
||||
$raw_json = json_decode( $buffer, true );
|
||||
if ( is_array( $raw_json ) ) {
|
||||
// OpenAI format: choices[0].message.content
|
||||
if ( isset( $raw_json['choices'][0]['message']['content'] ) ) {
|
||||
$accumulated_content = $raw_json['choices'][0]['message']['content'];
|
||||
error_log( 'WPAW Local Backend: Extracted content via OpenAI format fallback (' . strlen( $accumulated_content ) . ' chars)' );
|
||||
}
|
||||
// Anthropic format: content[0].text
|
||||
elseif ( isset( $raw_json['content'][0]['text'] ) ) {
|
||||
$accumulated_content = $raw_json['content'][0]['text'];
|
||||
error_log( 'WPAW Local Backend: Extracted content via Anthropic format fallback (' . strlen( $accumulated_content ) . ' chars)' );
|
||||
}
|
||||
// Simple format: content string
|
||||
elseif ( isset( $raw_json['content'] ) && is_string( $raw_json['content'] ) ) {
|
||||
$accumulated_content = $raw_json['content'];
|
||||
error_log( 'WPAW Local Backend: Extracted content via simple format fallback (' . strlen( $accumulated_content ) . ' chars)' );
|
||||
}
|
||||
|
||||
if ( ! empty( $accumulated_content ) && $callback ) {
|
||||
// Emit the full content as a single chunk so the SSE handler picks it up
|
||||
call_user_func( $callback, $accumulated_content, false, $accumulated_content );
|
||||
call_user_func( $callback, '', true, $accumulated_content );
|
||||
}
|
||||
|
||||
// Extract usage if available
|
||||
if ( isset( $raw_json['usage'] ) ) {
|
||||
$accumulated_usage = $raw_json['usage'];
|
||||
}
|
||||
} else {
|
||||
error_log( 'WPAW Local Backend: Buffer is not valid JSON. First 300 chars: ' . substr( $buffer, 0, 300 ) );
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'content' => $accumulated_content,
|
||||
'model' => $this->model,
|
||||
'input_tokens' => $accumulated_usage['prompt_tokens'] ?? 0,
|
||||
'output_tokens' => $accumulated_usage['completion_tokens'] ?? 0,
|
||||
'total_tokens' => $accumulated_usage['total_tokens'] ?? 0,
|
||||
'cost' => 0,
|
||||
'generation_time' => microtime( true ) - $start_time,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image (not supported by local backend)
|
||||
*
|
||||
* @param string $prompt Image prompt.
|
||||
* @param string $model Model to use.
|
||||
* @param array $options Optional parameters.
|
||||
* @return WP_Error Error indicating not supported.
|
||||
*/
|
||||
public function generate_image( $prompt, $model = null, $options = array() ) {
|
||||
return new WP_Error(
|
||||
'not_supported',
|
||||
__( 'Image generation not supported by Local Backend. Use OpenRouter for images.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is configured
|
||||
*
|
||||
* @return bool True if base URL is set.
|
||||
*/
|
||||
public function is_configured() {
|
||||
return ! empty( $this->base_url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to local backend
|
||||
*
|
||||
* @return array|WP_Error Success array or error.
|
||||
*/
|
||||
public function test_connection() {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return new WP_Error(
|
||||
'not_configured',
|
||||
__( 'Local Backend URL not configured', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
// Test /ping endpoint
|
||||
$ping_response = wp_remote_get(
|
||||
$this->base_url . '/ping',
|
||||
array(
|
||||
'timeout' => 5,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $ping_response ) ) {
|
||||
return new WP_Error(
|
||||
'ping_failed',
|
||||
sprintf(
|
||||
/* translators: %s: error message */
|
||||
__( 'Cannot reach proxy: %s. Is it running?', 'wp-agentic-writer' ),
|
||||
$ping_response->get_error_message()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$ping_body = wp_remote_retrieve_body( $ping_response );
|
||||
if ( 'pong' !== $ping_body ) {
|
||||
return new WP_Error(
|
||||
'invalid_ping',
|
||||
__( 'Proxy responded but with unexpected format', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
// Test actual inference with simple prompt
|
||||
$test_response = wp_remote_post(
|
||||
$this->base_url . '/v1/messages',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Content-Type' => 'application/json',
|
||||
),
|
||||
'body' => wp_json_encode(
|
||||
array(
|
||||
'messages' => array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => 'Reply with exactly: Connection test successful',
|
||||
),
|
||||
),
|
||||
)
|
||||
),
|
||||
'timeout' => 30,
|
||||
'sslverify' => false,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $test_response ) ) {
|
||||
return new WP_Error(
|
||||
'inference_failed',
|
||||
sprintf(
|
||||
/* translators: %s: error message */
|
||||
__( 'Inference test failed: %s', 'wp-agentic-writer' ),
|
||||
$test_response->get_error_message()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$test_body = json_decode( wp_remote_retrieve_body( $test_response ), true );
|
||||
if ( ! isset( $test_body['choices'][0]['message']['content'] ) ) {
|
||||
return new WP_Error(
|
||||
'invalid_response',
|
||||
__( 'Claude CLI not responding correctly. Check proxy logs.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => __( 'Connected! Proxy responding correctly.', 'wp-agentic-writer' ),
|
||||
'sample_response' => $test_body['choices'][0]['message']['content'],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider supports task type
|
||||
*
|
||||
* @param string $type Task type.
|
||||
* @return bool True if supported (all text tasks).
|
||||
*/
|
||||
public function supports_task_type( $type ) {
|
||||
// Local backend supports all text tasks, but not images
|
||||
return in_array(
|
||||
$type,
|
||||
array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $markdown Markdown content.
|
||||
* @param array $image_placeholders Optional. Array of image placeholder data with agent_image_id.
|
||||
* @return array Array of Gutenberg blocks.
|
||||
*/
|
||||
public static function parse( $markdown ) {
|
||||
public static function parse( $markdown, $image_placeholders = array() ) {
|
||||
$markdown = self::normalize_markdown( $markdown );
|
||||
$blocks = array();
|
||||
$lines = explode( "\n", $markdown );
|
||||
@@ -39,6 +40,7 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$in_auto_code_block = false;
|
||||
$auto_code_lines = array();
|
||||
$auto_code_language = 'text';
|
||||
$image_index = 0;
|
||||
$is_code_like_line = function( $trimmed ) {
|
||||
if ( '' === $trimmed ) {
|
||||
return false;
|
||||
@@ -92,8 +94,15 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$in_list = false;
|
||||
}
|
||||
|
||||
// Create image placeholder block.
|
||||
$blocks[] = self::create_image_placeholder_block( $matches[1] );
|
||||
// Get agent_image_id from placeholders array if available
|
||||
$agent_image_id = null;
|
||||
if ( ! empty( $image_placeholders[ $image_index ] ) ) {
|
||||
$agent_image_id = $image_placeholders[ $image_index ]['agent_image_id'] ?? null;
|
||||
}
|
||||
|
||||
// Create image placeholder block with agent_image_id
|
||||
$blocks[] = self::create_image_placeholder_block( $matches[1], $agent_image_id );
|
||||
$image_index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -258,6 +267,23 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle numbered items with bold title (treat as paragraph, not list).
|
||||
if ( preg_match( '/^(\d+)\.\s+\*\*(.+?)\*\*\s*$/', $trimmed, $matches ) ) {
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
}
|
||||
// Create paragraph with manual numbering and bold title.
|
||||
$content = $matches[1] . '. <strong>' . self::parse_inline_markdown( $matches[2] ) . '</strong>';
|
||||
$blocks[] = self::create_paragraph_block( $content );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle ordered lists.
|
||||
if ( preg_match( '/^\d+\.\s+(.+)$/', $trimmed, $matches ) ) {
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
@@ -639,20 +665,35 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $description Image description/alt text.
|
||||
* @param string $agent_image_id Optional. Agent-assigned image ID for tracking.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_image_placeholder_block( $description ) {
|
||||
private static function create_image_placeholder_block( $description, $agent_image_id = null ) {
|
||||
$alt = trim( $description );
|
||||
|
||||
// Build className with agent image ID (WordPress preserves className reliably)
|
||||
$class_name = '';
|
||||
if ( ! empty( $agent_image_id ) ) {
|
||||
$class_name = 'wpaw-agent-img-' . esc_attr( $agent_image_id );
|
||||
}
|
||||
|
||||
$attrs = array(
|
||||
'id' => 0,
|
||||
'url' => '',
|
||||
'alt' => $alt,
|
||||
'alt' => '[Image: ' . $alt . ']', // Mark as placeholder
|
||||
'caption' => '',
|
||||
'sizeSlug' => 'large',
|
||||
'linkDestination' => 'none',
|
||||
);
|
||||
|
||||
$html = '<figure class="wp-block-image size-large"><img alt="' . esc_attr( $alt ) . '" /></figure>';
|
||||
// Add className and data attribute if agent_image_id provided
|
||||
if ( ! empty( $agent_image_id ) ) {
|
||||
$attrs['className'] = $class_name;
|
||||
$attrs['data-agent-image-id'] = $agent_image_id;
|
||||
}
|
||||
|
||||
$figure_class = 'wp-block-image size-large' . ( $class_name ? ' ' . $class_name : '' );
|
||||
$html = '<figure class="' . esc_attr( $figure_class ) . '"><img alt="[Image: ' . esc_attr( $alt ) . ']" data-agent-image-id="' . esc_attr( $agent_image_id ) . '" /></figure>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/image',
|
||||
|
||||
@@ -17,7 +17,7 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class WP_Agentic_Writer_OpenRouter_Provider {
|
||||
class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Provider_Interface {
|
||||
|
||||
/**
|
||||
* API key.
|
||||
@@ -598,51 +598,130 @@ class WP_Agentic_Writer_OpenRouter_Provider {
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image.
|
||||
* Generate image using OpenRouter image generation API.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $prompt Image prompt.
|
||||
* @return array|WP_Error Response array with image URL or WP_Error on failure.
|
||||
* @param string $model Image model (optional, uses default if not provided).
|
||||
* @param array $options Additional options (size, quality, n).
|
||||
* @return array|WP_Error Response with image URL or error.
|
||||
*/
|
||||
public function generate_image( $prompt ) {
|
||||
// Check API key.
|
||||
public function generate_image( $prompt, $model = null, $options = array() ) {
|
||||
if ( empty( $this->api_key ) ) {
|
||||
return new WP_Error(
|
||||
'no_api_key',
|
||||
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
return new WP_Error( 'no_api_key', 'OpenRouter API key not configured' );
|
||||
}
|
||||
|
||||
$messages = array(
|
||||
$model = $model ?? $this->image_model;
|
||||
$size = $options['size'] ?? '1024x576';
|
||||
$quality = $options['quality'] ?? 'hd';
|
||||
$n = $options['n'] ?? 1;
|
||||
|
||||
$start_time = microtime( true );
|
||||
|
||||
$response = wp_remote_post(
|
||||
'https://openrouter.ai/api/v1/images/generations',
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => sprintf(
|
||||
'Generate an image based on this prompt: %s. Return only the image URL.',
|
||||
$prompt
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => get_bloginfo( 'name' ),
|
||||
),
|
||||
),
|
||||
'body' => wp_json_encode(
|
||||
array(
|
||||
'model' => $model,
|
||||
'prompt' => $prompt,
|
||||
'n' => $n,
|
||||
'size' => $size,
|
||||
'quality' => $quality,
|
||||
)
|
||||
),
|
||||
'timeout' => 60,
|
||||
)
|
||||
);
|
||||
|
||||
$response = $this->chat( $messages, array( 'model' => $this->image_model ), 'image' );
|
||||
$generation_time = microtime( true ) - $start_time;
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Extract image URL from response.
|
||||
$content = $response['content'];
|
||||
$url = '';
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
// Try to extract URL from content.
|
||||
if ( preg_match( '/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp)/i', $content, $matches ) ) {
|
||||
$url = $matches[0];
|
||||
if ( ! isset( $body['data'][0]['url'] ) ) {
|
||||
return new WP_Error(
|
||||
'image_generation_failed',
|
||||
$body['error']['message'] ?? 'Unknown error'
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'url' => $url,
|
||||
'prompt' => $prompt,
|
||||
'cost' => $response['cost'],
|
||||
'model' => $response['model'],
|
||||
'url' => $body['data'][0]['url'],
|
||||
'cost' => $body['usage']['cost'] ?? 0.03,
|
||||
'generation_time' => $generation_time,
|
||||
'model' => $model,
|
||||
'prompt' => $prompt,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is configured
|
||||
*
|
||||
* @return bool True if API key is set.
|
||||
*/
|
||||
public function is_configured() {
|
||||
return ! empty( $this->api_key );
|
||||
}
|
||||
|
||||
/**
|
||||
* Test connection to OpenRouter API
|
||||
*
|
||||
* @return array|WP_Error Success array or error.
|
||||
*/
|
||||
public function test_connection() {
|
||||
if ( ! $this->is_configured() ) {
|
||||
return new WP_Error( 'not_configured', 'OpenRouter API key not configured' );
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
'https://openrouter.ai/api/v1/models',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
),
|
||||
'timeout' => 10,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
return new WP_Error(
|
||||
'connection_failed',
|
||||
sprintf( 'OpenRouter API returned status %d', $code )
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'message' => 'Connected to OpenRouter successfully',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider supports task type
|
||||
*
|
||||
* @param string $type Task type.
|
||||
* @return bool True (OpenRouter supports all task types).
|
||||
*/
|
||||
public function supports_task_type( $type ) {
|
||||
return in_array(
|
||||
$type,
|
||||
array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' ),
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
152
includes/class-provider-manager.php
Normal file
152
includes/class-provider-manager.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
/**
|
||||
* AI Provider Manager
|
||||
*
|
||||
* Routes AI requests to appropriate provider based on task type and configuration
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Provider_Manager {
|
||||
/**
|
||||
* Get provider instance for specific task type
|
||||
*
|
||||
* @param string $type Task type (chat, clarity, planning, writing, refinement, image).
|
||||
* @return WP_Agentic_Writer_AI_Provider_Interface Provider instance.
|
||||
*/
|
||||
public static function get_provider_for_task( $type ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$task_providers = $settings['task_providers'] ?? array();
|
||||
|
||||
// Determine which provider to use for this task
|
||||
$provider_name = $task_providers[ $type ] ?? 'openrouter';
|
||||
|
||||
// Get provider instance with fallback logic
|
||||
$provider = self::get_provider_instance( $provider_name, $type );
|
||||
|
||||
// If provider not configured or unavailable, fallback to OpenRouter
|
||||
if ( ! $provider || ! $provider->is_configured() ) {
|
||||
error_log( "Provider '{$provider_name}' not available for task '{$type}', using OpenRouter fallback" );
|
||||
return WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider instance by name
|
||||
*
|
||||
* @param string $provider_name Provider identifier.
|
||||
* @param string $task_type Task type for validation.
|
||||
* @return WP_Agentic_Writer_AI_Provider_Interface|null Provider instance or null.
|
||||
*/
|
||||
private static function get_provider_instance( $provider_name, $task_type ) {
|
||||
switch ( $provider_name ) {
|
||||
case 'local_backend':
|
||||
if ( ! class_exists( 'WP_Agentic_Writer_Local_Backend_Provider' ) ) {
|
||||
require_once plugin_dir_path( __FILE__ ) . 'class-local-backend-provider.php';
|
||||
}
|
||||
$provider = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
break;
|
||||
|
||||
case 'codex':
|
||||
if ( ! class_exists( 'WP_Agentic_Writer_Codex_Provider' ) ) {
|
||||
require_once plugin_dir_path( __FILE__ ) . 'class-codex-provider.php';
|
||||
}
|
||||
$provider = new WP_Agentic_Writer_Codex_Provider();
|
||||
break;
|
||||
|
||||
case 'openrouter':
|
||||
default:
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
break;
|
||||
}
|
||||
|
||||
// Validate provider supports this task type
|
||||
if ( $provider && ! $provider->supports_task_type( $task_type ) ) {
|
||||
error_log( "Provider '{$provider_name}' does not support task type '{$task_type}'" );
|
||||
return null;
|
||||
}
|
||||
|
||||
return $provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all available providers with their status
|
||||
*
|
||||
* @return array Array of provider info with name, status, supported tasks.
|
||||
*/
|
||||
public static function get_available_providers() {
|
||||
$providers = array();
|
||||
|
||||
// OpenRouter (always available)
|
||||
$openrouter = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$providers['openrouter'] = array(
|
||||
'name' => 'OpenRouter',
|
||||
'configured' => $openrouter->is_configured(),
|
||||
'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' ),
|
||||
'icon' => '☁️',
|
||||
);
|
||||
|
||||
// Local Backend
|
||||
if ( class_exists( 'WP_Agentic_Writer_Local_Backend_Provider' ) ) {
|
||||
$local = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
$providers['local_backend'] = array(
|
||||
'name' => 'Local Backend',
|
||||
'configured' => $local->is_configured(),
|
||||
'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
|
||||
'icon' => '🏠',
|
||||
);
|
||||
}
|
||||
|
||||
// Codex
|
||||
if ( class_exists( 'WP_Agentic_Writer_Codex_Provider' ) ) {
|
||||
$codex = new WP_Agentic_Writer_Codex_Provider();
|
||||
$providers['codex'] = array(
|
||||
'name' => 'Codex (OpenAI)',
|
||||
'configured' => $codex->is_configured(),
|
||||
'supports' => array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ),
|
||||
'icon' => '🔗',
|
||||
);
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test all configured providers
|
||||
*
|
||||
* @return array Results of connection tests.
|
||||
*/
|
||||
public static function test_all_providers() {
|
||||
$results = array();
|
||||
$providers = self::get_available_providers();
|
||||
|
||||
foreach ( $providers as $key => $info ) {
|
||||
if ( ! $info['configured'] ) {
|
||||
$results[ $key ] = array(
|
||||
'success' => false,
|
||||
'message' => 'Not configured',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
$provider = self::get_provider_instance( $key, 'chat' );
|
||||
if ( $provider ) {
|
||||
$test_result = $provider->test_connection();
|
||||
$results[ $key ] = is_wp_error( $test_result )
|
||||
? array(
|
||||
'success' => false,
|
||||
'message' => $test_result->get_error_message(),
|
||||
)
|
||||
: $test_result;
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
}
|
||||
234
includes/class-seo-schema.php
Normal file
234
includes/class-seo-schema.php
Normal file
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
/**
|
||||
* SEO & Schema Injector
|
||||
*
|
||||
* @package WPAgenticWriter
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_SEO_Schema {
|
||||
|
||||
/**
|
||||
* Instance of this class.
|
||||
*
|
||||
* @var WP_Agentic_Writer_SEO_Schema
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get the singleton instance.
|
||||
*
|
||||
* @return WP_Agentic_Writer_SEO_Schema
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
add_action( 'save_post', array( $this, 'extract_and_save_faq_schema' ), 20, 3 );
|
||||
add_action( 'wp_head', array( $this, 'output_faq_schema_in_head' ) );
|
||||
|
||||
// Integrate with Yoast SEO schema graph
|
||||
add_filter( 'wpseo_schema_graph', array( $this, 'inject_into_yoast_schema' ) );
|
||||
|
||||
// Integrate with RankMath SEO schema graph
|
||||
add_filter( 'rank_math/json_ld', array( $this, 'inject_into_rankmath_schema' ), 99, 2 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse post content when saved, looking for Q&A structures
|
||||
* to build an FAQPage JSON-LD array.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param WP_Post $post Post object.
|
||||
* @param bool $update Whether this is an existing post being updated.
|
||||
*/
|
||||
public function extract_and_save_faq_schema( $post_id, $post, $update ) {
|
||||
// Don't run on autosaves or revisions.
|
||||
if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if FAQ schema is enabled in settings
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$enable_faq_schema = $settings['enable_faq_schema'] ?? false;
|
||||
if ( ! $enable_faq_schema ) {
|
||||
delete_post_meta( $post_id, '_wpaw_faq_schema' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Only run for active post types (e.g. post, page).
|
||||
$allowed_types = array( 'post', 'page' );
|
||||
if ( ! in_array( $post->post_type, $allowed_types, true ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $post->post_content;
|
||||
if ( empty( $content ) ) {
|
||||
delete_post_meta( $post_id, '_wpaw_faq_schema' );
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip Gutenberg block HTML comments to make regex matching easier.
|
||||
$clean_content = preg_replace( '/<!-- wp:[^>]*-->/s', '', $content );
|
||||
$clean_content = preg_replace( '/<!-- \/wp:[^>]*-->/s', '', $clean_content );
|
||||
|
||||
// Regex to find H2, H3, or H4 that contain a question mark, immediately followed by a paragraph.
|
||||
// Matches: <hX>Question?</hX> <p>Answer</p>
|
||||
$pattern = '/<h([2-4])[^>]*>(.*?\?)<\/h\1>[\s]*<p[^>]*>(.*?)<\/p>/is';
|
||||
|
||||
$faqs = array();
|
||||
|
||||
if ( preg_match_all( $pattern, $clean_content, $matches, PREG_SET_ORDER ) ) {
|
||||
foreach ( $matches as $match ) {
|
||||
$question = wp_strip_all_tags( $match[2] );
|
||||
$answer = wp_strip_all_tags( $match[3] );
|
||||
|
||||
// Basic validation: question must not be too short, answer must have some length.
|
||||
if ( strlen( $question ) > 10 && strlen( $answer ) > 15 ) {
|
||||
$faqs[] = array(
|
||||
'@type' => 'Question',
|
||||
'name' => $question,
|
||||
'acceptedAnswer' => array(
|
||||
'@type' => 'Answer',
|
||||
'text' => $answer,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only save schema if we actually detected 1 or more valid FAQ pairs.
|
||||
if ( ! empty( $faqs ) ) {
|
||||
$schema = array(
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'FAQPage',
|
||||
'mainEntity' => $faqs,
|
||||
);
|
||||
update_post_meta( $post_id, '_wpaw_faq_schema', wp_json_encode( $schema, JSON_UNESCAPED_UNICODE ) );
|
||||
} else {
|
||||
delete_post_meta( $post_id, '_wpaw_faq_schema' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output the JSON-LD schema script in the frontend <head>.
|
||||
* This serves as a fallback for sites without a primary SEO plugin.
|
||||
*/
|
||||
public function output_faq_schema_in_head() {
|
||||
if ( ! is_singular() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Abort if feature is manually disabled in Settings
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent duplicate output if a major SEO plugin is active and handling our schema
|
||||
if ( defined( 'WPSEO_VERSION' ) || class_exists( 'RankMath' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$post_id = get_the_ID();
|
||||
|
||||
// Attempt to fetch schema generated by WP Agentic Writer.
|
||||
$schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
|
||||
|
||||
if ( ! empty( $schema_json ) ) {
|
||||
echo "\n<!-- WP Agentic Writer: Automated FAQ Schema -->\n";
|
||||
echo '<script type="application/ld+json">' . $schema_json . '</script>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo "\n<!-- /WP Agentic Writer Schema -->\n";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the FAQ schema directly into Yoast SEO's JSON-LD graph.
|
||||
* This prevents disjointed schema and consolidates everything into Yoast's payload.
|
||||
*
|
||||
* @param array $graph The Yoast schema graph array.
|
||||
* @return array
|
||||
*/
|
||||
public function inject_into_yoast_schema( $graph ) {
|
||||
if ( ! is_singular() ) {
|
||||
return $graph;
|
||||
}
|
||||
|
||||
// Abort if feature is manually disabled in Settings
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
|
||||
return $graph;
|
||||
}
|
||||
|
||||
$post_id = get_the_ID();
|
||||
|
||||
// Fetch our pre-computed schema
|
||||
$schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
|
||||
|
||||
if ( ! empty( $schema_json ) ) {
|
||||
$schema_array = json_decode( $schema_json, true );
|
||||
if ( is_array( $schema_array ) ) {
|
||||
// Yoast specifically expects nodes with an @id property
|
||||
$schema_array['@id'] = get_permalink( $post_id ) . '#wpaw-faq';
|
||||
|
||||
// Yoast wraps all schemas in a global @context, so we can unset our local one to remain clean
|
||||
if ( isset( $schema_array['@context'] ) ) {
|
||||
unset( $schema_array['@context'] );
|
||||
}
|
||||
|
||||
// Append our FAQ schema snippet to Yoast's massive graph
|
||||
$graph[] = $schema_array;
|
||||
}
|
||||
}
|
||||
|
||||
return $graph;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the FAQ schema directly into RankMath's JSON-LD output.
|
||||
* This prevents disjointed schema and consolidates everything into RankMath's payload.
|
||||
*
|
||||
* @param array $data The RankMath JSON-LD data array.
|
||||
* @param object $jsonld The RankMath JsonLd object.
|
||||
* @return array
|
||||
*/
|
||||
public function inject_into_rankmath_schema( $data, $jsonld ) {
|
||||
if ( ! is_singular() ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
// Abort if feature is manually disabled in Settings
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
if ( isset( $settings['enable_faq_schema'] ) && ! $settings['enable_faq_schema'] ) {
|
||||
return $data;
|
||||
}
|
||||
|
||||
$post_id = get_the_ID();
|
||||
|
||||
// Fetch our pre-computed schema
|
||||
$schema_json = get_post_meta( $post_id, '_wpaw_faq_schema', true );
|
||||
|
||||
if ( ! empty( $schema_json ) ) {
|
||||
$schema_array = json_decode( $schema_json, true );
|
||||
if ( is_array( $schema_array ) && ! empty( $schema_array['mainEntity'] ) ) {
|
||||
// RankMath expects a keyed array for each schema type
|
||||
$data['WPAWFaqPage'] = array(
|
||||
'@type' => 'FAQPage',
|
||||
'mainEntity' => $schema_array['mainEntity'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
add_action( 'wp_ajax_wpaw_debug_models', array( $this, 'ajax_debug_models' ) );
|
||||
add_action( 'wp_ajax_wpaw_save_custom_model', array( $this, 'ajax_save_custom_model' ) );
|
||||
add_action( 'wp_ajax_wpaw_delete_custom_model', array( $this, 'ajax_delete_custom_model' ) );
|
||||
add_action( 'wp_ajax_wpaw_test_local_backend', array( $this, 'ajax_test_local_backend' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,9 +80,17 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
wp_enqueue_style( 'wpaw-agentic-components', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-components.css', array( 'wpaw-agentic-variables' ), WP_AGENTIC_WRITER_VERSION );
|
||||
|
||||
// Legacy plugin styles
|
||||
wp_enqueue_style( 'wp-agentic-writer-admin-v2', WP_AGENTIC_WRITER_URL . 'assets/css/admin-v2.css', array( 'bootstrap', 'select2-bootstrap-5' ), WP_AGENTIC_WRITER_VERSION );
|
||||
wp_enqueue_style( 'wp-agentic-writer-settings-v2', WP_AGENTIC_WRITER_URL . 'assets/css/settings-v2.css', array( 'wpaw-agentic-components' ), WP_AGENTIC_WRITER_VERSION );
|
||||
wp_enqueue_style( 'wp-agentic-writer-cost-log-grouped', WP_AGENTIC_WRITER_URL . 'assets/css/cost-log-grouped.css', array( 'wp-agentic-writer-settings-v2' ), WP_AGENTIC_WRITER_VERSION );
|
||||
$css_admin_path = WP_AGENTIC_WRITER_DIR . 'assets/css/admin-v2.css';
|
||||
$css_settings_path = WP_AGENTIC_WRITER_DIR . 'assets/css/settings-v2.css';
|
||||
$css_log_path = WP_AGENTIC_WRITER_DIR . 'assets/css/cost-log-grouped.css';
|
||||
|
||||
$ver_admin = file_exists($css_admin_path) ? filemtime($css_admin_path) : WP_AGENTIC_WRITER_VERSION;
|
||||
$ver_settings = file_exists($css_settings_path) ? filemtime($css_settings_path) : WP_AGENTIC_WRITER_VERSION;
|
||||
$ver_log = file_exists($css_log_path) ? filemtime($css_log_path) : WP_AGENTIC_WRITER_VERSION;
|
||||
|
||||
wp_enqueue_style( 'wp-agentic-writer-admin-v2', WP_AGENTIC_WRITER_URL . 'assets/css/admin-v2.css', array( 'bootstrap', 'select2-bootstrap-5' ), $ver_admin );
|
||||
wp_enqueue_style( 'wp-agentic-writer-settings-v2', WP_AGENTIC_WRITER_URL . 'assets/css/settings-v2.css', array( 'wpaw-agentic-components' ), $ver_settings );
|
||||
wp_enqueue_style( 'wp-agentic-writer-cost-log-grouped', WP_AGENTIC_WRITER_URL . 'assets/css/cost-log-grouped.css', array( 'wp-agentic-writer-settings-v2' ), $ver_log );
|
||||
|
||||
// Plugin scripts
|
||||
wp_enqueue_script( 'wp-agentic-writer-settings-v2', WP_AGENTIC_WRITER_URL . 'assets/js/settings-v2.js', array( 'jquery', 'bootstrap', 'select2' ), WP_AGENTIC_WRITER_VERSION, true );
|
||||
@@ -968,8 +977,13 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
public function sanitize_settings( $input ) {
|
||||
$sanitized = array();
|
||||
|
||||
// Sanitize API key
|
||||
$sanitized['openrouter_api_key'] = trim( $input['openrouter_api_key'] ?? '' );
|
||||
// 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'] );
|
||||
}
|
||||
|
||||
// Sanitize model names (6 models)
|
||||
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? 'google/gemini-2.5-flash' );
|
||||
@@ -988,6 +1002,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
$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;
|
||||
|
||||
// Sanitize search options
|
||||
$sanitized['search_engine'] = in_array( $input['search_engine'] ?? '', array( 'auto', 'native', 'exa' ), true )
|
||||
@@ -1030,6 +1045,38 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
$sanitized['custom_languages'] = array();
|
||||
}
|
||||
|
||||
// Sanitize Local Backend 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_model'] ) ) {
|
||||
$sanitized['local_backend_model'] = sanitize_text_field( trim( $input['local_backend_model'] ) );
|
||||
}
|
||||
|
||||
// Sanitize Task Providers Routing
|
||||
if ( isset( $input['task_providers'] ) && is_array( $input['task_providers'] ) ) {
|
||||
$sanitized_providers = array();
|
||||
$allowed_tasks = array( 'chat', 'clarity', 'planning', 'writing', 'refinement', 'image' );
|
||||
$allowed_providers_text = array( '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 ( 'image' === $task && 'openrouter' === $provider ) {
|
||||
$sanitized_providers[ $task ] = $provider;
|
||||
} elseif ( 'image' !== $task && in_array( $provider, $allowed_providers_text, true ) ) {
|
||||
$sanitized_providers[ $task ] = $provider;
|
||||
}
|
||||
}
|
||||
}
|
||||
$sanitized['task_providers'] = $sanitized_providers;
|
||||
}
|
||||
|
||||
return $sanitized;
|
||||
}
|
||||
|
||||
@@ -1058,6 +1105,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
private function prepare_view_data( $settings ) {
|
||||
// Extract settings (6 models)
|
||||
$api_key = $settings['openrouter_api_key'] ?? '';
|
||||
$brave_search_api_key = $settings['brave_search_api_key'] ?? '';
|
||||
$chat_model = $settings['chat_model'] ?? 'google/gemini-2.5-flash';
|
||||
$clarity_model = $settings['clarity_model'] ?? 'google/gemini-2.5-flash';
|
||||
$planning_model = $settings['planning_model'] ?? 'google/gemini-2.5-flash';
|
||||
@@ -1071,6 +1119,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
$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'] ?? array(
|
||||
'target_outcome',
|
||||
@@ -1085,6 +1134,12 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
$custom_languages = $settings['custom_languages'] ?? array();
|
||||
$custom_models = get_option( 'wp_agentic_writer_custom_models', array() );
|
||||
|
||||
// Local Backend settings
|
||||
$local_backend_url = $settings['local_backend_url'] ?? '';
|
||||
$local_backend_key = $settings['local_backend_key'] ?? 'dummy';
|
||||
$local_backend_model = $settings['local_backend_model'] ?? 'claude-local';
|
||||
$task_providers = $settings['task_providers'] ?? array();
|
||||
|
||||
// Get cost tracking data
|
||||
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
|
||||
$monthly_used = $cost_tracker->get_monthly_total();
|
||||
@@ -1093,6 +1148,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
|
||||
return compact(
|
||||
'api_key',
|
||||
'brave_search_api_key',
|
||||
'chat_model',
|
||||
'clarity_model',
|
||||
'planning_model',
|
||||
@@ -1106,6 +1162,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
'monthly_budget',
|
||||
'chat_history_limit',
|
||||
'enable_clarification_quiz',
|
||||
'enable_faq_schema',
|
||||
'clarity_confidence_threshold',
|
||||
'required_context_categories',
|
||||
'preferred_languages',
|
||||
@@ -1113,7 +1170,12 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
'custom_models',
|
||||
'monthly_used',
|
||||
'budget_percent',
|
||||
'budget_status'
|
||||
'budget_status',
|
||||
'local_backend_url',
|
||||
'local_backend_key',
|
||||
'local_backend_model',
|
||||
'task_providers',
|
||||
'settings'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1151,4 +1213,37 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
'Swedish' => 'Swedish (Svenska)',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler: Test local backend connection
|
||||
*
|
||||
* @since 0.2.0
|
||||
*/
|
||||
public function ajax_test_local_backend() {
|
||||
check_ajax_referer( 'wpaw_test_local_backend', 'nonce' );
|
||||
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_send_json_error( array( 'message' => 'Insufficient permissions' ) );
|
||||
}
|
||||
|
||||
$url = sanitize_text_field( wp_unslash( $_POST['url'] ?? '' ) );
|
||||
|
||||
if ( empty( $url ) ) {
|
||||
wp_send_json_error( array( 'message' => 'URL required' ) );
|
||||
}
|
||||
|
||||
// Temporarily create provider with this URL
|
||||
$temp_settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$temp_settings['local_backend_url'] = $url;
|
||||
update_option( 'wp_agentic_writer_settings', $temp_settings );
|
||||
|
||||
$provider = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
$result = $provider->test_connection();
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
wp_send_json_error( array( 'message' => $result->get_error_message() ) );
|
||||
}
|
||||
|
||||
wp_send_json_success( $result );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,8 +318,9 @@ class WP_Agentic_Writer_Settings {
|
||||
public function sanitize_settings( $input ) {
|
||||
$sanitized = array();
|
||||
|
||||
// Sanitize API key (don't strip tags, but trim).
|
||||
// Sanitize API keys (don't strip tags, but trim).
|
||||
$sanitized['openrouter_api_key'] = trim( $input['openrouter_api_key'] ?? '' );
|
||||
$sanitized['brave_search_api_key'] = trim( $input['brave_search_api_key'] ?? '' );
|
||||
|
||||
// Sanitize model names (6 models as per model-preset-brief.md).
|
||||
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? 'google/gemini-2.5-flash' );
|
||||
@@ -392,6 +393,7 @@ class WP_Agentic_Writer_Settings {
|
||||
|
||||
// Extract settings (6 models as per model-preset-brief.md).
|
||||
$api_key = $settings['openrouter_api_key'] ?? '';
|
||||
$brave_api_key = $settings['brave_search_api_key'] ?? '';
|
||||
$chat_model = $settings['chat_model'] ?? 'google/gemini-2.5-flash';
|
||||
$clarity_model = $settings['clarity_model'] ?? 'google/gemini-2.5-flash';
|
||||
$planning_model = $settings['planning_model'] ?? 'google/gemini-2.5-flash';
|
||||
@@ -1029,7 +1031,7 @@ class WP_Agentic_Writer_Settings {
|
||||
planning: 'google/gemini-2.5-flash',
|
||||
writing: 'mistralai/mistral-small-creative',
|
||||
refinement: 'google/gemini-2.5-flash',
|
||||
image: 'openai/gpt-4o'
|
||||
image: 'black-forest-labs/flux.2-klein'
|
||||
},
|
||||
balanced: {
|
||||
chat: 'google/gemini-2.5-flash',
|
||||
@@ -1037,7 +1039,7 @@ class WP_Agentic_Writer_Settings {
|
||||
planning: 'google/gemini-2.5-flash',
|
||||
writing: 'anthropic/claude-3.5-sonnet',
|
||||
refinement: 'anthropic/claude-3.5-sonnet',
|
||||
image: 'openai/gpt-4o'
|
||||
image: 'sourceful/riverflow-v2-max'
|
||||
},
|
||||
premium: {
|
||||
chat: 'google/gemini-3-flash-preview',
|
||||
@@ -1045,7 +1047,7 @@ class WP_Agentic_Writer_Settings {
|
||||
planning: 'google/gemini-3-flash-preview',
|
||||
writing: 'openai/gpt-4.1',
|
||||
refinement: 'openai/gpt-4.1',
|
||||
image: 'openai/gpt-4o'
|
||||
image: 'black-forest-labs/flux.2-max'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
67
includes/interface-ai-provider.php
Normal file
67
includes/interface-ai-provider.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
/**
|
||||
* AI Provider Interface
|
||||
*
|
||||
* Common contract for all AI providers (OpenRouter, Local Backend, Codex, etc.)
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
interface WP_Agentic_Writer_AI_Provider_Interface {
|
||||
/**
|
||||
* Non-streaming chat completion
|
||||
*
|
||||
* @param array $messages Array of message objects with 'role' and 'content'.
|
||||
* @param array $options Optional parameters (temperature, max_tokens, etc.).
|
||||
* @param string $type Task type (chat, clarity, planning, writing, refinement).
|
||||
* @return array|WP_Error Response with content, model, tokens, cost, or error.
|
||||
*/
|
||||
public function chat( $messages, $options = array(), $type = 'planning' );
|
||||
|
||||
/**
|
||||
* Streaming chat completion
|
||||
*
|
||||
* @param array $messages Array of message objects.
|
||||
* @param array $options Optional parameters.
|
||||
* @param string $type Task type.
|
||||
* @param callable $callback Function to call with each chunk (chunk, is_complete, accumulated).
|
||||
* @return array|WP_Error Response with accumulated content, tokens, cost, or error.
|
||||
*/
|
||||
public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null );
|
||||
|
||||
/**
|
||||
* Generate image
|
||||
*
|
||||
* @param string $prompt Image generation prompt.
|
||||
* @param string $model Model to use (optional, uses provider default).
|
||||
* @param array $options Optional parameters (size, quality, n).
|
||||
* @return array|WP_Error Response with url, model, cost, or error.
|
||||
*/
|
||||
public function generate_image( $prompt, $model = null, $options = array() );
|
||||
|
||||
/**
|
||||
* Check if provider is properly configured
|
||||
*
|
||||
* @return bool True if configured and ready to use.
|
||||
*/
|
||||
public function is_configured();
|
||||
|
||||
/**
|
||||
* Test connection to provider
|
||||
*
|
||||
* @return array|WP_Error Success array or error details.
|
||||
*/
|
||||
public function test_connection();
|
||||
|
||||
/**
|
||||
* Check if provider supports a specific task type
|
||||
*
|
||||
* @param string $type Task type (chat, clarity, planning, writing, refinement, image).
|
||||
* @return bool True if supported.
|
||||
*/
|
||||
public function supports_task_type( $type );
|
||||
}
|
||||
Reference in New Issue
Block a user