first commit all files
This commit is contained in:
201
includes/class-admin-columns.php
Normal file
201
includes/class-admin-columns.php
Normal file
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
/**
|
||||
* Admin Columns Handler
|
||||
*
|
||||
* Adds custom columns to post list tables.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin Columns Handler Class
|
||||
*/
|
||||
class WP_Agentic_Writer_Admin_Columns {
|
||||
|
||||
/**
|
||||
* Instance of this class.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Admin_Columns
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get instance.
|
||||
*
|
||||
* @return WP_Agentic_Writer_Admin_Columns
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
// Use the specific post type hooks for 'post' post type
|
||||
add_filter( 'manage_post_posts_columns', array( $this, 'add_cost_column' ) );
|
||||
add_action( 'manage_post_posts_custom_column', array( $this, 'render_cost_column' ), 10, 2 );
|
||||
add_filter( 'manage_edit-post_sortable_columns', array( $this, 'make_cost_column_sortable' ) );
|
||||
add_action( 'pre_get_posts', array( $this, 'sort_by_cost' ) );
|
||||
add_action( 'admin_head', array( $this, 'custom_css' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add cost column to post list table.
|
||||
*
|
||||
* @param array $columns Existing columns.
|
||||
* @return array Modified columns.
|
||||
*/
|
||||
public function add_cost_column( $columns ) {
|
||||
// Insert cost column before date column
|
||||
$new_columns = array();
|
||||
foreach ( $columns as $key => $value ) {
|
||||
if ( $key === 'date' ) {
|
||||
$new_columns['wp_aw_cost'] = '💰 AI Cost';
|
||||
}
|
||||
$new_columns[ $key ] = $value;
|
||||
}
|
||||
return $new_columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render cost column content.
|
||||
*
|
||||
* @param string $column Column name.
|
||||
* @param int $post_id Post ID.
|
||||
*/
|
||||
public function render_cost_column( $column, $post_id ) {
|
||||
if ( $column !== 'wp_aw_cost' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
// Get total cost for this post
|
||||
$total_cost = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(cost) FROM {$table_name} WHERE post_id = %d",
|
||||
$post_id
|
||||
)
|
||||
);
|
||||
|
||||
if ( $total_cost && $total_cost > 0 ) {
|
||||
// Color code based on cost
|
||||
$color = '#61ff86ff'; // Green
|
||||
if ( $total_cost > 0.5 ) {
|
||||
$color = '#fac62aff'; // Yellow
|
||||
}
|
||||
if ( $total_cost > 1.0 ) {
|
||||
$color = '#fe717fff'; // Red
|
||||
}
|
||||
|
||||
printf(
|
||||
'<span style="color: %s; font-weight: 600; font-family: ui-monospace, monospace;">$%s</span>',
|
||||
esc_attr( $color ),
|
||||
esc_html( number_format( $total_cost, 4 ) )
|
||||
);
|
||||
} else {
|
||||
echo '<span style="color: #999; font-family: ui-monospace, monospace;">-</span>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make cost column sortable.
|
||||
*
|
||||
* @param array $columns Sortable columns.
|
||||
* @return array Modified columns.
|
||||
*/
|
||||
public function make_cost_column_sortable( $columns ) {
|
||||
$columns['wp_aw_cost'] = 'wp_aw_cost';
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort posts by cost when column is clicked.
|
||||
*
|
||||
* @param WP_Query $query Query object.
|
||||
*/
|
||||
public function sort_by_cost( $query ) {
|
||||
if ( ! is_admin() || ! $query->is_main_query() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$orderby = $query->get( 'orderby' );
|
||||
if ( $orderby !== 'wp_aw_cost' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
// Join with costs table and order by sum
|
||||
$query->set( 'meta_query', array() );
|
||||
add_filter( 'posts_join', array( $this, 'cost_join' ) );
|
||||
add_filter( 'posts_orderby', array( $this, 'cost_orderby' ) );
|
||||
add_filter( 'posts_groupby', array( $this, 'cost_groupby' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Join with costs table.
|
||||
*
|
||||
* @param string $join Join clause.
|
||||
* @return string Modified join clause.
|
||||
*/
|
||||
public function cost_join( $join ) {
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
$join .= " LEFT JOIN {$table_name} ON {$wpdb->posts}.ID = {$table_name}.post_id";
|
||||
return $join;
|
||||
}
|
||||
|
||||
/**
|
||||
* Order by cost sum.
|
||||
*
|
||||
* @param string $orderby Order by clause.
|
||||
* @return string Modified order by clause.
|
||||
*/
|
||||
public function cost_orderby( $orderby ) {
|
||||
global $wpdb;
|
||||
$order = isset( $_GET['order'] ) && $_GET['order'] === 'asc' ? 'ASC' : 'DESC';
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
return "SUM({$table_name}.cost) {$order}";
|
||||
}
|
||||
|
||||
/**
|
||||
* Group by post ID.
|
||||
*
|
||||
* @param string $groupby Group by clause.
|
||||
* @return string Modified group by clause.
|
||||
*/
|
||||
public function cost_groupby( $groupby ) {
|
||||
global $wpdb;
|
||||
if ( ! $groupby ) {
|
||||
$groupby = "{$wpdb->posts}.ID";
|
||||
}
|
||||
return $groupby;
|
||||
}
|
||||
|
||||
public function custom_css() {
|
||||
?>
|
||||
<style>
|
||||
th#wp_aw_cost a > span:first-child, td.wp_aw_cost.column-wp_aw_cost > span {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
background: #1e1e1e;
|
||||
padding: 3px 6px;
|
||||
color: #cacaca;
|
||||
}
|
||||
</style>
|
||||
<?php
|
||||
}
|
||||
}
|
||||
74
includes/class-autoloader.php
Normal file
74
includes/class-autoloader.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
/**
|
||||
* Autoloader for WP Agentic Writer classes.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Autoloader
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Autoloader {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Autoloader
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_Agentic_Writer_Autoloader
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
private function __construct() {
|
||||
spl_autoload_register( array( $this, 'autoload' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Autoload classes.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $class Class name.
|
||||
*/
|
||||
public function autoload( $class ) {
|
||||
// Check if class is in our namespace.
|
||||
if ( strpos( $class, 'WP_Agentic_Writer_' ) !== 0 ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove namespace prefix.
|
||||
$class_name = str_replace( 'WP_Agentic_Writer_', '', $class );
|
||||
$class_name = strtolower( str_replace( '_', '-', $class_name ) );
|
||||
|
||||
// Build file path.
|
||||
$file_path = WP_AGENTIC_WRITER_DIR . 'includes/class-' . $class_name . '.php';
|
||||
|
||||
// Include file if exists.
|
||||
if ( file_exists( $file_path ) ) {
|
||||
require_once $file_path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WP_Agentic_Writer_Autoloader::get_instance();
|
||||
240
includes/class-cost-tracker.php
Normal file
240
includes/class-cost-tracker.php
Normal file
@@ -0,0 +1,240 @@
|
||||
<?php
|
||||
/**
|
||||
* Cost Tracker
|
||||
*
|
||||
* Tracks and displays API costs for user sessions.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Cost_Tracker
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Cost_Tracker {
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_Agentic_Writer_Cost_Tracker
|
||||
*/
|
||||
public static function get_instance() {
|
||||
static $instance = null;
|
||||
|
||||
if ( null === $instance ) {
|
||||
$instance = new self();
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
private function __construct() {
|
||||
// Hooks for tracking costs.
|
||||
add_action( 'wp_aw_after_api_request', array( $this, 'add_request' ), 10, 6 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add API request to cost tracking.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $model Model name.
|
||||
* @param string $action Action type (planning, execution, research, image).
|
||||
* @param int $input_tokens Input tokens.
|
||||
* @param int $output_tokens Output tokens.
|
||||
* @param float $cost Cost in USD.
|
||||
*/
|
||||
public function add_request( $post_id, $model, $action, $input_tokens, $output_tokens, $cost ) {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
$wpdb->insert(
|
||||
$table_name,
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'model' => $model,
|
||||
'action' => $action,
|
||||
'input_tokens' => $input_tokens,
|
||||
'output_tokens' => $output_tokens,
|
||||
'cost' => $cost,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%d', '%s', '%s', '%d', '%d', '%f', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session total cost.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param int $post_id Post ID.
|
||||
* @return float Session total cost.
|
||||
*/
|
||||
public function get_session_total( $post_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
$total = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(cost) FROM {$table_name} WHERE post_id = %d",
|
||||
$post_id
|
||||
)
|
||||
);
|
||||
|
||||
return floatval( $total );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get monthly total cost.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return float Monthly total cost.
|
||||
*/
|
||||
public function get_monthly_total() {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
$total = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(cost) FROM {$table_name} WHERE created_at >= %s",
|
||||
date( 'Y-m-01 00:00:00' )
|
||||
)
|
||||
);
|
||||
|
||||
return floatval( $total );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's usage breakdown.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return array Today's usage.
|
||||
*/
|
||||
public function get_today_usage() {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT action, SUM(input_tokens + output_tokens) as tokens, SUM(cost) as cost
|
||||
FROM {$table_name}
|
||||
WHERE created_at >= %s
|
||||
GROUP BY action",
|
||||
date( 'Y-m-d 00:00:00' )
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
$usage = array();
|
||||
$total_cost = 0;
|
||||
$total_tokens = 0;
|
||||
|
||||
foreach ( $results as $row ) {
|
||||
$usage[ $row['action'] ] = array(
|
||||
'tokens' => intval( $row['tokens'] ),
|
||||
'cost' => floatval( $row['cost'] ),
|
||||
);
|
||||
$total_cost += floatval( $row['cost'] );
|
||||
$total_tokens += intval( $row['tokens'] );
|
||||
}
|
||||
|
||||
$usage['total'] = array(
|
||||
'cost' => $total_cost,
|
||||
'tokens' => $total_tokens,
|
||||
);
|
||||
|
||||
return $usage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format cost for display.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param float $cost Cost in USD.
|
||||
* @return string Formatted cost.
|
||||
*/
|
||||
public function format_cost( $cost ) {
|
||||
return '$' . number_format( $cost, 4, '.', ',' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tokens for display.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param int $tokens Number of tokens.
|
||||
* @return string Formatted tokens.
|
||||
*/
|
||||
public function format_tokens( $tokens ) {
|
||||
return number_format( $tokens ) . ' tokens';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed cost history for a post.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $limit Number of records to return.
|
||||
* @return array Cost history records.
|
||||
*/
|
||||
public function get_post_history( $post_id, $limit = 50 ) {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
$results = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$table_name} WHERE post_id = %d ORDER BY created_at DESC LIMIT %d",
|
||||
$post_id,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost tracking data for frontend.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param int $post_id Post ID.
|
||||
* @return array Cost tracking data.
|
||||
*/
|
||||
public function get_frontend_data( $post_id = 0 ) {
|
||||
$today_usage = $this->get_today_usage();
|
||||
$monthly_total = $this->get_monthly_total();
|
||||
$session_total = $post_id > 0 ? $this->get_session_total( $post_id ) : 0;
|
||||
$post_history = $post_id > 0 ? $this->get_post_history( $post_id ) : array();
|
||||
|
||||
// Get settings for budget.
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$monthly_budget = $settings['monthly_budget'] ?? 600;
|
||||
|
||||
return array(
|
||||
'today' => $today_usage,
|
||||
'monthly' => array(
|
||||
'used' => $monthly_total,
|
||||
'budget' => $monthly_budget,
|
||||
'percentage' => $monthly_budget > 0 ? ( $monthly_total / $monthly_budget ) * 100 : 0,
|
||||
'remaining' => max( 0, $monthly_budget - $monthly_total ),
|
||||
),
|
||||
'session' => $session_total,
|
||||
'history' => $post_history,
|
||||
);
|
||||
}
|
||||
}
|
||||
5432
includes/class-gutenberg-sidebar.php
Normal file
5432
includes/class-gutenberg-sidebar.php
Normal file
File diff suppressed because it is too large
Load Diff
156
includes/class-keyword-suggester.php
Normal file
156
includes/class-keyword-suggester.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
/**
|
||||
* Keyword Suggester Helper
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
* @since 0.1.0
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Keyword_Suggester
|
||||
*
|
||||
* Helper class for AI-powered keyword suggestions.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Keyword_Suggester {
|
||||
|
||||
/**
|
||||
* Suggest keywords based on outline and title.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $title Article title.
|
||||
* @param array $sections Outline sections.
|
||||
* @param string $language Language code.
|
||||
* @param int $post_id Post ID for cost tracking.
|
||||
* @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();
|
||||
|
||||
// Build outline text from sections
|
||||
$outline_text = '';
|
||||
if ( is_array( $sections ) ) {
|
||||
foreach ( $sections as $section ) {
|
||||
$section_title = $section['title'] ?? '';
|
||||
if ( ! empty( $section_title ) ) {
|
||||
$outline_text .= "- {$section_title}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( empty( $outline_text ) ) {
|
||||
return new WP_Error( 'no_outline', 'No outline available for keyword analysis' );
|
||||
}
|
||||
|
||||
// Build language-specific instruction
|
||||
$language_instruction = self::build_language_instruction( $language );
|
||||
|
||||
// Build prompt for keyword suggestion
|
||||
$prompt = "Analyze this article outline and suggest optimal SEO keywords.\n\n";
|
||||
$prompt .= "ARTICLE TITLE:\n{$title}\n\n";
|
||||
$prompt .= "OUTLINE SECTIONS:\n{$outline_text}\n\n";
|
||||
$prompt .= "TASK:\n";
|
||||
$prompt .= "1. Suggest ONE focus keyword (2-4 words) that best represents the main topic\n";
|
||||
$prompt .= "2. Suggest 3-5 secondary keywords (related terms, variations, or supporting topics)\n";
|
||||
$prompt .= "3. Provide brief reasoning (1-2 sentences) for your suggestions\n\n";
|
||||
$prompt .= "REQUIREMENTS:\n";
|
||||
$prompt .= "- Focus keyword should be specific and searchable\n";
|
||||
$prompt .= "- Secondary keywords should complement the focus keyword\n";
|
||||
$prompt .= "- Consider search intent and user queries\n";
|
||||
$prompt .= "- Keywords should match the outline content\n\n";
|
||||
$prompt .= "{$language_instruction}\n\n";
|
||||
$prompt .= "Respond ONLY with valid JSON in this exact format:\n";
|
||||
$prompt .= "{\n";
|
||||
$prompt .= " \"focus_keyword\": \"your suggested focus keyword\",\n";
|
||||
$prompt .= " \"secondary_keywords\": [\"keyword1\", \"keyword2\", \"keyword3\"],\n";
|
||||
$prompt .= " \"reasoning\": \"Brief explanation of why these keywords are optimal\"\n";
|
||||
$prompt .= "}\n\n";
|
||||
$prompt .= "Do not include any text outside the JSON structure.";
|
||||
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
),
|
||||
);
|
||||
|
||||
// Use planning model for keyword suggestion (fast and cheap)
|
||||
$response = $provider->chat( $messages, array( 'temperature' => 0.3 ), 'planning' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$content = trim( $response['content'] ?? '' );
|
||||
|
||||
// Try to extract JSON from response
|
||||
$json_start = strpos( $content, '{' );
|
||||
$json_end = strrpos( $content, '}' );
|
||||
|
||||
if ( false === $json_start || false === $json_end ) {
|
||||
return new WP_Error( 'invalid_response', 'AI response is not valid JSON' );
|
||||
}
|
||||
|
||||
$json_string = substr( $content, $json_start, $json_end - $json_start + 1 );
|
||||
$suggestions = json_decode( $json_string, true );
|
||||
|
||||
if ( null === $suggestions || ! is_array( $suggestions ) ) {
|
||||
return new WP_Error( 'parse_error', 'Failed to parse keyword suggestions' );
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if ( empty( $suggestions['focus_keyword'] ) || empty( $suggestions['secondary_keywords'] ) ) {
|
||||
return new WP_Error( 'incomplete_suggestions', 'Keyword suggestions are incomplete' );
|
||||
}
|
||||
|
||||
// Ensure secondary_keywords is an array
|
||||
if ( ! is_array( $suggestions['secondary_keywords'] ) ) {
|
||||
$suggestions['secondary_keywords'] = array();
|
||||
}
|
||||
|
||||
// Track cost with separate operation type
|
||||
$cost = $response['cost'] ?? 0;
|
||||
if ( $cost > 0 && $post_id > 0 ) {
|
||||
do_action(
|
||||
'wp_aw_after_api_request',
|
||||
$post_id,
|
||||
$response['model'] ?? 'unknown',
|
||||
'suggest_keyword',
|
||||
$response['input_tokens'] ?? 0,
|
||||
$response['output_tokens'] ?? 0,
|
||||
$cost
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'focus_keyword' => $suggestions['focus_keyword'],
|
||||
'secondary_keywords' => $suggestions['secondary_keywords'],
|
||||
'reasoning' => $suggestions['reasoning'] ?? '',
|
||||
'cost' => $cost,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build language instruction for keyword suggestion.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $language Language code.
|
||||
* @return string Language instruction.
|
||||
*/
|
||||
private static function build_language_instruction( $language ) {
|
||||
$language = trim( (string) $language );
|
||||
|
||||
// If auto or empty, match article language
|
||||
if ( empty( $language ) || 'auto' === strtolower( $language ) ) {
|
||||
return 'Suggest keywords in the same language as the article topic and outline.';
|
||||
}
|
||||
|
||||
// Pass any language name directly - AI understands all languages
|
||||
return "IMPORTANT: Suggest keywords in {$language}. Consider {$language} search queries and terms used by {$language} speakers.";
|
||||
}
|
||||
}
|
||||
788
includes/class-markdown-parser.php
Normal file
788
includes/class-markdown-parser.php
Normal file
@@ -0,0 +1,788 @@
|
||||
<?php
|
||||
/**
|
||||
* Markdown to Gutenberg Blocks Parser
|
||||
*
|
||||
* Converts Markdown content to WordPress Gutenberg blocks.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Markdown_Parser
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Markdown_Parser {
|
||||
|
||||
/**
|
||||
* Parse Markdown content and convert to Gutenberg blocks.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $markdown Markdown content.
|
||||
* @return array Array of Gutenberg blocks.
|
||||
*/
|
||||
public static function parse( $markdown ) {
|
||||
$markdown = self::normalize_markdown( $markdown );
|
||||
$blocks = array();
|
||||
$lines = explode( "\n", $markdown );
|
||||
$current_paragraph = '';
|
||||
$in_code_block = false;
|
||||
$in_list = false;
|
||||
$list_items = array();
|
||||
$list_type = 'ul'; // 'ul' or 'ol'
|
||||
$code_lines = array();
|
||||
$code_language = '';
|
||||
$in_auto_code_block = false;
|
||||
$auto_code_lines = array();
|
||||
$auto_code_language = 'text';
|
||||
$is_code_like_line = function( $trimmed ) {
|
||||
if ( '' === $trimmed ) {
|
||||
return false;
|
||||
}
|
||||
if ( preg_match( '/^(<\\?php|define\\s*\\(|\\$[A-Za-z_]|function\\s+\\w+|class\\s+\\w+|if\\s*\\(|elseif\\s*\\(|else\\b|foreach\\s*\\(|for\\s*\\(|while\\s*\\(|switch\\s*\\(|case\\s+|echo\\s+|return\\s+|const\\s+|public\\s+|private\\s+|protected\\s+)/i', $trimmed ) ) {
|
||||
return true;
|
||||
}
|
||||
if ( preg_match( '/[;{}]$/', $trimmed ) && preg_match( '/[()$=]|->|::/', $trimmed ) ) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
$line_count = count( $lines );
|
||||
for ( $i = 0; $i < $line_count; $i++ ) {
|
||||
$line = $lines[ $i ];
|
||||
$trimmed = trim( $line );
|
||||
|
||||
if ( $in_auto_code_block ) {
|
||||
if ( '' === $trimmed ) {
|
||||
$blocks[] = self::create_code_block( $auto_code_language, implode( "\n", $auto_code_lines ) );
|
||||
$auto_code_lines = array();
|
||||
$auto_code_language = 'text';
|
||||
$in_auto_code_block = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $is_code_like_line( $trimmed ) || preg_match( '/^\\s+/', $line ) ) {
|
||||
$auto_code_lines[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
$blocks[] = self::create_code_block( $auto_code_language, implode( "\n", $auto_code_lines ) );
|
||||
$auto_code_lines = array();
|
||||
$auto_code_language = 'text';
|
||||
$in_auto_code_block = false;
|
||||
// Continue processing current line normally below.
|
||||
}
|
||||
|
||||
// Handle image placeholders: [IMAGE: description]
|
||||
if ( preg_match( '/^\[IMAGE:\s*(.+)\]$/i', $trimmed, $matches ) ) {
|
||||
// Flush any pending paragraph.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
}
|
||||
|
||||
// Create image placeholder block.
|
||||
$blocks[] = self::create_image_placeholder_block( $matches[1] );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle CTA/Button placeholders: [CTA: text] or [CTA: text (url)]
|
||||
if ( preg_match( '/^\[CTA:\s*(.+)\]$/i', $trimmed, $matches ) ) {
|
||||
// Flush any pending paragraph.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
}
|
||||
|
||||
// Create button block.
|
||||
$blocks[] = self::create_button_block( $matches[1] );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect unfenced code lines and create a code block automatically.
|
||||
if ( ! $in_code_block && $is_code_like_line( $trimmed ) ) {
|
||||
// Flush any pending paragraph.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
}
|
||||
|
||||
$auto_code_language = preg_match( '/^(<\\?php|define\\s*\\(|\\$[A-Za-z_])/', $trimmed ) ? 'php' : 'text';
|
||||
$in_auto_code_block = true;
|
||||
$auto_code_lines[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle code blocks.
|
||||
if ( preg_match( '/^```(\w*)/', $trimmed, $matches ) ) {
|
||||
if ( $in_code_block ) {
|
||||
// End code block.
|
||||
$blocks[] = self::create_code_block( $code_language, implode( "\n", $code_lines ) );
|
||||
$code_lines = array();
|
||||
$code_language = '';
|
||||
$in_code_block = false;
|
||||
} else {
|
||||
// Start code block.
|
||||
// Flush any pending paragraph.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
}
|
||||
$in_code_block = true;
|
||||
$code_language = $matches[1];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( $in_code_block ) {
|
||||
$code_lines[] = $line;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle headings.
|
||||
if ( preg_match( '/^(#{1,6})\s+(.+)$/', $trimmed, $matches ) ) {
|
||||
// Flush any pending paragraph.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
}
|
||||
|
||||
$level = strlen( $matches[1] );
|
||||
$content = $matches[2];
|
||||
$blocks[] = self::create_heading_block( $content, $level );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle markdown tables (header + separator + rows).
|
||||
if ( ! $in_list && self::is_table_row( $trimmed ) && $i + 1 < $line_count ) {
|
||||
$next_line = trim( $lines[ $i + 1 ] );
|
||||
if ( self::is_table_separator( $next_line ) ) {
|
||||
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;
|
||||
}
|
||||
|
||||
$headers = self::split_table_row( $trimmed );
|
||||
$rows = array();
|
||||
$i += 2;
|
||||
for ( ; $i < $line_count; $i++ ) {
|
||||
$row_line = trim( $lines[ $i ] );
|
||||
if ( '' === $row_line || ! self::is_table_row( $row_line ) ) {
|
||||
$i -= 1;
|
||||
break;
|
||||
}
|
||||
$rows[] = self::split_table_row( $row_line );
|
||||
}
|
||||
|
||||
$blocks[] = self::create_table_block( $headers, $rows );
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle horizontal rules.
|
||||
if ( preg_match( '/^[-*_]{3,}/', $trimmed ) ) {
|
||||
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;
|
||||
}
|
||||
// Gutenberg doesn't have a native separator block, use a spacer.
|
||||
$blocks[] = array(
|
||||
'blockName' => 'core/spacer',
|
||||
'attrs' => array(
|
||||
'height' => '20px',
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array(),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle unordered lists (supports common dash/bullet variants).
|
||||
if ( preg_match( '/^[\*\-+\x{2022}\x{2023}\x{2013}\x{2014}]\s+(.+)$/u', $trimmed, $matches ) ) {
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list && $list_type !== 'ul' ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
}
|
||||
$in_list = true;
|
||||
$list_type = 'ul';
|
||||
$list_items[] = self::parse_inline_markdown( $matches[1] );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle ordered lists.
|
||||
if ( preg_match( '/^\d+\.\s+(.+)$/', $trimmed, $matches ) ) {
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list && $list_type !== 'ol' ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
}
|
||||
$in_list = true;
|
||||
$list_type = 'ol';
|
||||
$list_items[] = self::parse_inline_markdown( $matches[1] );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle blockquotes.
|
||||
if ( preg_match( '/^>\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;
|
||||
}
|
||||
$blocks[] = self::create_quote_block( $matches[1] );
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle empty lines.
|
||||
if ( empty( $trimmed ) ) {
|
||||
// Flush paragraph.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
// Flush list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Accumulate paragraph text.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$current_paragraph .= ' ' . $trimmed;
|
||||
} else {
|
||||
$current_paragraph = $trimmed;
|
||||
}
|
||||
|
||||
// Check if paragraph ends with punctuation suggesting end of sentence.
|
||||
if ( preg_match( '/[.!?]$/', $trimmed ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
$current_paragraph = '';
|
||||
}
|
||||
}
|
||||
|
||||
if ( $in_auto_code_block && ! empty( $auto_code_lines ) ) {
|
||||
$blocks[] = self::create_code_block( $auto_code_language, implode( "\n", $auto_code_lines ) );
|
||||
}
|
||||
|
||||
// Flush any remaining content.
|
||||
if ( ! empty( $current_paragraph ) ) {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
}
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
}
|
||||
|
||||
// Merge consecutive ordered lists (fix 1. 1. 1. issue)
|
||||
$merged_blocks = self::merge_consecutive_ordered_lists( $blocks );
|
||||
|
||||
// Remove duplicate adjacent headings before returning
|
||||
$cleaned_blocks = array();
|
||||
$last_heading_content = null;
|
||||
|
||||
foreach ( $merged_blocks as $block ) {
|
||||
if ( isset( $block['blockName'] ) && 'core/heading' === $block['blockName'] ) {
|
||||
if ( preg_match( '/<h[1-6]>(.+?)<\/h[1-6]>/i', $block['innerHTML'], $matches ) ) {
|
||||
$current_heading = trim( $matches[1] );
|
||||
|
||||
// Skip if duplicate of last heading (case-insensitive)
|
||||
if ( null !== $last_heading_content && strtolower( $current_heading ) === strtolower( $last_heading_content ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$last_heading_content = $current_heading;
|
||||
}
|
||||
} else {
|
||||
$last_heading_content = null;
|
||||
}
|
||||
|
||||
$cleaned_blocks[] = $block;
|
||||
}
|
||||
|
||||
return $cleaned_blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse inline Markdown elements (bold, italic, code, links).
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $text Text with inline Markdown.
|
||||
* @return array HTML content with inline formatting.
|
||||
*/
|
||||
private static function parse_inline_markdown( $text ) {
|
||||
// Convert inline code.
|
||||
$text = preg_replace( '/`([^`]+)`/', '<code>$1</code>', $text );
|
||||
|
||||
// Convert bold.
|
||||
$text = preg_replace( '/\*\*(.+?)\*\*/', '<strong>$1</strong>', $text );
|
||||
$text = preg_replace( '/__(.+?)__/', '<strong>$1</strong>', $text );
|
||||
|
||||
// Convert italic.
|
||||
$text = preg_replace( '/\*(.+?)\*/', '<em>$1</em>', $text );
|
||||
$text = preg_replace( '/_(.+?)_/', '<em>$1</em>', $text );
|
||||
|
||||
// Convert links.
|
||||
$text = preg_replace( '/\[(.+?)\]\((.+?)\)/', '<a href="$2">$1</a>', $text );
|
||||
|
||||
return $text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize markdown input to improve block parsing.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $markdown Raw markdown or HTML-ish content.
|
||||
* @return string
|
||||
*/
|
||||
private static function normalize_markdown( $markdown ) {
|
||||
if ( null === $markdown ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$markdown = (string) $markdown;
|
||||
$markdown = str_replace(
|
||||
array( '–', '—', '–', '—', '•' ),
|
||||
'-',
|
||||
$markdown
|
||||
);
|
||||
$markdown = preg_replace( '/<br\\s*\\/?>/i', "\n", $markdown );
|
||||
$markdown = preg_replace( '/<\\/p>\\s*<p>/i', "\n\n", $markdown );
|
||||
$markdown = preg_replace( '/<\\/?p>/i', '', $markdown );
|
||||
$markdown = preg_replace( '/!\\[([^\\]]*)\\]\\([^\\)]*\\)/', '[IMAGE: $1]', $markdown );
|
||||
$markdown = preg_replace( '/\\[IMAGE:\\s*([^\\]]+)\\]/i', "\n[IMAGE: $1]\n", $markdown );
|
||||
|
||||
return $markdown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line looks like a markdown table row.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $line Line content.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_table_row( $line ) {
|
||||
if ( '' === $line ) {
|
||||
return false;
|
||||
}
|
||||
return false !== strpos( $line, '|' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a line is a markdown table separator.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $line Line content.
|
||||
* @return bool
|
||||
*/
|
||||
private static function is_table_separator( $line ) {
|
||||
return (bool) preg_match( '/^\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?$/', $line );
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a markdown table row into cells.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $line Row line.
|
||||
* @return array
|
||||
*/
|
||||
private static function split_table_row( $line ) {
|
||||
$trimmed = trim( $line );
|
||||
$trimmed = trim( $trimmed, " \t|" );
|
||||
if ( '' === $trimmed ) {
|
||||
return array();
|
||||
}
|
||||
return array_map( 'trim', explode( '|', $trimmed ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a heading block.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $content Heading content.
|
||||
* @param int $level Heading level (1-6).
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_heading_block( $content, $level ) {
|
||||
$level = min( max( $level, 1 ), 6 ); // Ensure level is between 1-6.
|
||||
$parsed_content = self::parse_inline_markdown( $content );
|
||||
$html = '<h' . $level . '>' . $parsed_content . '</h' . $level . '>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/heading',
|
||||
'attrs' => array(
|
||||
'level' => $level,
|
||||
'content' => $parsed_content,
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array( $html ),
|
||||
'innerHTML' => $html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a paragraph block.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $content Paragraph content.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_paragraph_block( $content ) {
|
||||
$parsed_content = self::parse_inline_markdown( $content );
|
||||
$html = '<p>' . $parsed_content . '</p>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/paragraph',
|
||||
'attrs' => array(
|
||||
'content' => $parsed_content,
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array( $html ),
|
||||
'innerHTML' => $html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a table block.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $headers Table headers.
|
||||
* @param array $rows Table rows.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_table_block( $headers, $rows ) {
|
||||
$header_cells = array();
|
||||
foreach ( $headers as $header ) {
|
||||
$header_cells[] = '<th>' . self::parse_inline_markdown( $header ) . '</th>';
|
||||
}
|
||||
|
||||
$tbody_rows = array();
|
||||
foreach ( $rows as $row ) {
|
||||
$cells = array();
|
||||
foreach ( $row as $cell ) {
|
||||
$cells[] = '<td>' . self::parse_inline_markdown( $cell ) . '</td>';
|
||||
}
|
||||
if ( empty( $cells ) ) {
|
||||
continue;
|
||||
}
|
||||
$tbody_rows[] = '<tr>' . implode( '', $cells ) . '</tr>';
|
||||
}
|
||||
|
||||
$thead_html = '<thead><tr>' . implode( '', $header_cells ) . '</tr></thead>';
|
||||
$tbody_html = '<tbody>' . implode( '', $tbody_rows ) . '</tbody>';
|
||||
$table_html = '<figure class="wp-block-table"><table>' . $thead_html . $tbody_html . '</table></figure>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/table',
|
||||
'attrs' => array(
|
||||
'hasFixedLayout' => true,
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array( $table_html ),
|
||||
'innerHTML' => $table_html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a code block.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $language Programming language.
|
||||
* @param string $code Code content.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_code_block( $language, $code ) {
|
||||
// Escape HTML entities in code.
|
||||
$escaped_code = htmlspecialchars( $code, ENT_NOQUOTES, 'UTF-8' );
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/code',
|
||||
'attrs' => array(
|
||||
'content' => $code,
|
||||
'language' => $language ?: 'text',
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array(),
|
||||
'innerHTML' => '<pre class="wp-block-code"><code>' . $escaped_code . '</code></pre>',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a list block.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $type List type ('ul' or 'ol').
|
||||
* @param array $items List items.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_list_block( $type, $items ) {
|
||||
$tag = $type === 'ol' ? 'ol' : 'ul';
|
||||
$html = '<' . $tag . '>';
|
||||
|
||||
// Create inner blocks for each list item
|
||||
$inner_blocks = array();
|
||||
$inner_content = array();
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
$item_content = self::parse_inline_markdown( $item );
|
||||
$li_html = '<li>' . $item_content . '</li>';
|
||||
$html .= $li_html;
|
||||
$inner_content[] = $li_html;
|
||||
|
||||
// Create list item with content in attrs
|
||||
$inner_blocks[] = array(
|
||||
'blockName' => 'core/list-item',
|
||||
'attrs' => array(
|
||||
'content' => $item_content,
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array( $li_html ),
|
||||
'innerHTML' => $li_html,
|
||||
);
|
||||
}
|
||||
|
||||
$html .= '</' . $tag . '>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/list',
|
||||
'attrs' => array(
|
||||
'ordered' => $type === 'ol',
|
||||
),
|
||||
'innerBlocks' => $inner_blocks,
|
||||
'innerContent' => $inner_content,
|
||||
'innerHTML' => $html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a quote block.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $content Quote content.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_quote_block( $content ) {
|
||||
$parsed_content = self::parse_inline_markdown( $content );
|
||||
$html = '<blockquote class="wp-block-quote"><p>' . $parsed_content . '</p></blockquote>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/quote',
|
||||
'attrs' => array(
|
||||
'value' => $parsed_content,
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array( $html ),
|
||||
'innerHTML' => $html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an image placeholder block.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $description Image description/alt text.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_image_placeholder_block( $description ) {
|
||||
$alt = trim( $description );
|
||||
$attrs = array(
|
||||
'id' => 0,
|
||||
'url' => '',
|
||||
'alt' => $alt,
|
||||
'caption' => '',
|
||||
'sizeSlug' => 'large',
|
||||
'linkDestination' => 'none',
|
||||
);
|
||||
|
||||
$html = '<figure class="wp-block-image size-large"><img alt="' . esc_attr( $alt ) . '" /></figure>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/image',
|
||||
'attrs' => $attrs,
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array(),
|
||||
'innerHTML' => $html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge consecutive ordered lists into one continuous list.
|
||||
* Fixes the "1. 1. 1." issue when numbered items are separated by other content.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $blocks Array of blocks.
|
||||
* @return array Merged blocks.
|
||||
*/
|
||||
private static function merge_consecutive_ordered_lists( $blocks ) {
|
||||
$result = array();
|
||||
$pending_ol = null;
|
||||
$pending_ol_items = array();
|
||||
|
||||
foreach ( $blocks as $block ) {
|
||||
$is_ordered_list = isset( $block['blockName'] )
|
||||
&& 'core/list' === $block['blockName']
|
||||
&& ! empty( $block['attrs']['ordered'] );
|
||||
|
||||
if ( $is_ordered_list ) {
|
||||
// Accumulate ordered list items
|
||||
if ( ! empty( $block['innerBlocks'] ) ) {
|
||||
foreach ( $block['innerBlocks'] as $item ) {
|
||||
$pending_ol_items[] = $item;
|
||||
}
|
||||
}
|
||||
if ( null === $pending_ol ) {
|
||||
$pending_ol = $block;
|
||||
}
|
||||
} else {
|
||||
// Flush pending ordered list if we have one
|
||||
if ( null !== $pending_ol && ! empty( $pending_ol_items ) ) {
|
||||
$result[] = self::rebuild_ordered_list( $pending_ol_items );
|
||||
$pending_ol = null;
|
||||
$pending_ol_items = array();
|
||||
}
|
||||
$result[] = $block;
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining ordered list
|
||||
if ( null !== $pending_ol && ! empty( $pending_ol_items ) ) {
|
||||
$result[] = self::rebuild_ordered_list( $pending_ol_items );
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild an ordered list from accumulated items.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $items List item blocks.
|
||||
* @return array Ordered list block.
|
||||
*/
|
||||
private static function rebuild_ordered_list( $items ) {
|
||||
$html = '<ol>';
|
||||
$inner_content = array();
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
$li_html = isset( $item['innerHTML'] ) ? $item['innerHTML'] : '<li></li>';
|
||||
$html .= $li_html;
|
||||
$inner_content[] = $li_html;
|
||||
}
|
||||
|
||||
$html .= '</ol>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/list',
|
||||
'attrs' => array(
|
||||
'ordered' => true,
|
||||
),
|
||||
'innerBlocks' => $items,
|
||||
'innerContent' => $inner_content,
|
||||
'innerHTML' => $html,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a button block from CTA syntax.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $content CTA content (may include URL in parentheses).
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_button_block( $content ) {
|
||||
$text = trim( $content );
|
||||
$url = '#';
|
||||
|
||||
// Check for URL in parentheses: "Button Text (https://example.com)"
|
||||
if ( preg_match( '/^(.+?)\s*\(([^)]+)\)\s*$/', $content, $matches ) ) {
|
||||
$text = trim( $matches[1] );
|
||||
$url = trim( $matches[2] );
|
||||
}
|
||||
|
||||
// Clean up common patterns like "Link ke..." or "(Link..."
|
||||
$text = preg_replace( '/\s*\(Link\s+ke\s+.*$/i', '', $text );
|
||||
$text = preg_replace( '/\s*\(Link\s+.*$/i', '', $text );
|
||||
|
||||
$button_html = '<div class="wp-block-button"><a class="wp-block-button__link wp-element-button" href="' . esc_attr( $url ) . '">' . esc_html( $text ) . '</a></div>';
|
||||
|
||||
$button_block = array(
|
||||
'blockName' => 'core/button',
|
||||
'attrs' => array(
|
||||
'text' => $text,
|
||||
'url' => $url,
|
||||
),
|
||||
'innerBlocks' => array(),
|
||||
'innerContent' => array( $button_html ),
|
||||
'innerHTML' => $button_html,
|
||||
);
|
||||
|
||||
// Wrap in buttons container
|
||||
$wrapper_html = '<div class="wp-block-buttons">' . $button_html . '</div>';
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/buttons',
|
||||
'attrs' => array(),
|
||||
'innerBlocks' => array( $button_block ),
|
||||
'innerContent' => array( $wrapper_html ),
|
||||
'innerHTML' => $wrapper_html,
|
||||
);
|
||||
}
|
||||
}
|
||||
648
includes/class-openrouter-provider.php
Normal file
648
includes/class-openrouter-provider.php
Normal file
@@ -0,0 +1,648 @@
|
||||
<?php
|
||||
/**
|
||||
* OpenRouter API Provider
|
||||
*
|
||||
* Handles all communication with OpenRouter API, including chat completion
|
||||
* and image generation.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_OpenRouter_Provider
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
class WP_Agentic_Writer_OpenRouter_Provider {
|
||||
|
||||
/**
|
||||
* API key.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $api_key = '';
|
||||
|
||||
/**
|
||||
* Chat model (discussion, research, recommendations).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $chat_model = 'google/gemini-2.5-flash';
|
||||
|
||||
/**
|
||||
* Clarity model (prompt analysis, quiz generation).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $clarity_model = 'google/gemini-2.5-flash';
|
||||
|
||||
/**
|
||||
* Planning model (article outline generation).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $planning_model = 'google/gemini-2.5-flash';
|
||||
|
||||
/**
|
||||
* Writing model (article draft generation).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $writing_model = 'anthropic/claude-3.5-sonnet';
|
||||
|
||||
/**
|
||||
* Refinement model (paragraph edits, rewrites).
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $refinement_model = 'anthropic/claude-3.5-sonnet';
|
||||
|
||||
/**
|
||||
* Image model.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $image_model = 'openai/gpt-4o';
|
||||
|
||||
/**
|
||||
* Web search enabled.
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $web_search_enabled = false;
|
||||
|
||||
/**
|
||||
* Search depth.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $search_depth = 'medium';
|
||||
|
||||
/**
|
||||
* Search engine.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $search_engine = 'auto';
|
||||
|
||||
/**
|
||||
* API endpoint.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $api_endpoint = 'https://openrouter.ai/api/v1/chat/completions';
|
||||
|
||||
/**
|
||||
* Get cached models from OpenRouter API.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return array|WP_Error Models array or WP_Error on failure.
|
||||
*/
|
||||
public function get_cached_models() {
|
||||
// Check if we have cached models.
|
||||
$cached_models = get_transient( 'wpaw_openrouter_models' );
|
||||
if ( false !== $cached_models ) {
|
||||
return $cached_models;
|
||||
}
|
||||
|
||||
// Check API key.
|
||||
if ( empty( $this->api_key ) ) {
|
||||
return new WP_Error(
|
||||
'no_api_key',
|
||||
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch all models from OpenRouter API.
|
||||
$response = wp_remote_get(
|
||||
'https://openrouter.ai/api/v1/models',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
),
|
||||
'timeout' => 30,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$data = json_decode( $body, true );
|
||||
|
||||
if ( isset( $data['error'] ) ) {
|
||||
return new WP_Error(
|
||||
'api_error',
|
||||
$data['error']['message'] ?? __( 'Unknown API error', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$models = $data['data'] ?? array();
|
||||
|
||||
// Debug: Log model count and categorize by output_modalities
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'OpenRouter API total models: ' . count( $models ) );
|
||||
|
||||
// Count models by output modality
|
||||
$text_count = 0;
|
||||
$image_count = 0;
|
||||
$image_model_ids = array();
|
||||
|
||||
foreach ( $models as $model ) {
|
||||
$output_modalities = $model['architecture']['output_modalities'] ?? array();
|
||||
if ( in_array( 'text', $output_modalities, true ) ) {
|
||||
$text_count++;
|
||||
}
|
||||
if ( in_array( 'image', $output_modalities, true ) ) {
|
||||
$image_count++;
|
||||
$image_model_ids[] = $model['id'] . ' (' . ( $model['name'] ?? 'N/A' ) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
error_log( "OpenRouter models by output_modalities: TEXT={$text_count}, IMAGE={$image_count}" );
|
||||
error_log( 'Image generation models: ' . implode( ', ', array_slice( $image_model_ids, 0, 20 ) ) );
|
||||
}
|
||||
|
||||
// Cache for 24 hours.
|
||||
set_transient( 'wpaw_openrouter_models', $models, DAY_IN_SECONDS );
|
||||
|
||||
return $models;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch models and refresh cache when requested.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param bool $force_refresh Whether to refresh cache.
|
||||
* @return array|WP_Error Models array or WP_Error on failure.
|
||||
*/
|
||||
public function fetch_and_cache_models( $force_refresh = false ) {
|
||||
if ( $force_refresh ) {
|
||||
delete_transient( 'wpaw_openrouter_models' );
|
||||
}
|
||||
|
||||
return $this->get_cached_models();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get writing model name (legacy: execution model).
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return string
|
||||
*/
|
||||
public function get_execution_model() {
|
||||
return $this->writing_model;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get model for a specific task type.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $type Task type (chat, clarity, planning, writing, execution, refinement).
|
||||
* @param array $options Options array that may contain 'model' override.
|
||||
* @return string Model ID.
|
||||
*/
|
||||
private function get_model_for_type( $type, $options = array() ) {
|
||||
if ( isset( $options['model'] ) ) {
|
||||
return $options['model'];
|
||||
}
|
||||
|
||||
switch ( $type ) {
|
||||
case 'chat':
|
||||
return $this->chat_model;
|
||||
case 'clarity':
|
||||
return $this->clarity_model;
|
||||
case 'writing':
|
||||
case 'execution':
|
||||
return $this->writing_model;
|
||||
case 'refinement':
|
||||
return $this->refinement_model;
|
||||
case 'planning':
|
||||
default:
|
||||
return $this->planning_model;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return WP_Agentic_Writer_OpenRouter_Provider
|
||||
*/
|
||||
public static function get_instance() {
|
||||
static $instance = null;
|
||||
|
||||
if ( null === $instance ) {
|
||||
$instance = new self();
|
||||
}
|
||||
|
||||
return $instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.1.0
|
||||
*/
|
||||
private function __construct() {
|
||||
// Get settings from the unified settings array.
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$this->api_key = $settings['openrouter_api_key'] ?? '';
|
||||
|
||||
// Get models from settings (6 models per model-preset-brief.md).
|
||||
$this->chat_model = $settings['chat_model'] ?? $this->chat_model;
|
||||
$this->clarity_model = $settings['clarity_model'] ?? $this->clarity_model;
|
||||
$this->planning_model = $settings['planning_model'] ?? $this->planning_model;
|
||||
$this->writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? $this->writing_model );
|
||||
$this->refinement_model = $settings['refinement_model'] ?? $this->refinement_model;
|
||||
$this->image_model = $settings['image_model'] ?? $this->image_model;
|
||||
|
||||
// Get web search settings.
|
||||
$this->web_search_enabled = isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled'];
|
||||
$this->search_depth = $settings['search_depth'] ?? 'medium';
|
||||
$this->search_engine = $settings['search_engine'] ?? 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat completion (non-streaming).
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $messages Chat messages.
|
||||
* @param array $options Additional options (model, max_tokens, etc.).
|
||||
* @param string $type Request type (planning or execution).
|
||||
* @return array|WP_Error Response array or WP_Error on failure.
|
||||
*/
|
||||
public function chat( $messages, $options = array(), $type = 'planning' ) {
|
||||
// Check API key.
|
||||
if ( empty( $this->api_key ) ) {
|
||||
return new WP_Error(
|
||||
'no_api_key',
|
||||
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$web_search_enabled = $this->web_search_enabled;
|
||||
if ( is_array( $options ) && array_key_exists( 'web_search_enabled', $options ) ) {
|
||||
$web_search_enabled = (bool) $options['web_search_enabled'];
|
||||
}
|
||||
$search_depth = $options['search_depth'] ?? $this->search_depth;
|
||||
$search_engine = $options['search_engine'] ?? $this->search_engine;
|
||||
|
||||
// Determine model based on type (6 models per model-preset-brief.md).
|
||||
$model = $this->get_model_for_type( $type, $options );
|
||||
|
||||
// Add :online suffix if web search is enabled.
|
||||
if ( $web_search_enabled && 'planning' === $type ) {
|
||||
$model .= ':online';
|
||||
}
|
||||
|
||||
// Build request body.
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'usage' => array(
|
||||
'include' => true,
|
||||
),
|
||||
);
|
||||
|
||||
// Add optional parameters.
|
||||
if ( isset( $options['max_tokens'] ) ) {
|
||||
$body['max_tokens'] = $options['max_tokens'];
|
||||
}
|
||||
|
||||
if ( isset( $options['temperature'] ) ) {
|
||||
$body['temperature'] = $options['temperature'];
|
||||
}
|
||||
|
||||
// Add web search options if enabled.
|
||||
if ( $web_search_enabled && 'planning' === $type ) {
|
||||
$body['plugins'] = array(
|
||||
array(
|
||||
'id' => 'web',
|
||||
'web_search_options' => array(
|
||||
'search_context_size' => $search_depth,
|
||||
'max_results' => 5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Set search engine if specified.
|
||||
if ( 'auto' !== $search_engine ) {
|
||||
$body['plugins'][0]['web_search_options']['engine'] = $search_engine;
|
||||
}
|
||||
}
|
||||
|
||||
// Send request.
|
||||
$response = wp_remote_post(
|
||||
$this->api_endpoint,
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
'Content-Type' => 'application/json',
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => 'WP Agentic Writer',
|
||||
),
|
||||
'body' => wp_json_encode( $body ),
|
||||
'timeout' => 120, // 2 minutes timeout.
|
||||
)
|
||||
);
|
||||
|
||||
// Check for errors.
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Get response body.
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$data = json_decode( $body, true );
|
||||
|
||||
// Check for API errors.
|
||||
if ( isset( $data['error'] ) ) {
|
||||
return new WP_Error(
|
||||
'api_error',
|
||||
$data['error']['message'] ?? __( 'Unknown API error', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
// Extract response data.
|
||||
$content = $data['choices'][0]['message']['content'] ?? '';
|
||||
$input_tokens = $data['usage']['prompt_tokens'] ?? 0;
|
||||
$output_tokens = $data['usage']['completion_tokens'] ?? 0;
|
||||
$cost = $data['usage']['cost'] ?? 0.0;
|
||||
|
||||
// Extract web search results if available.
|
||||
$web_search_results = array();
|
||||
if ( isset( $data['choices'][0]['message']['annotations'] ) ) {
|
||||
foreach ( $data['choices'][0]['message']['annotations'] as $annotation ) {
|
||||
if ( isset( $annotation['url'] ) ) {
|
||||
$web_search_results[] = array(
|
||||
'url' => $annotation['url'],
|
||||
'title' => $annotation['title'] ?? '',
|
||||
'description' => $annotation['description'] ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'content' => $content,
|
||||
'input_tokens' => $input_tokens,
|
||||
'output_tokens' => $output_tokens,
|
||||
'total_tokens' => $input_tokens + $output_tokens,
|
||||
'cost' => $cost,
|
||||
'model' => $model,
|
||||
'web_search_results' => $web_search_results,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream chat completion with callback for each chunk.
|
||||
*
|
||||
* This method streams the AI response token by token, calling the callback
|
||||
* function with each accumulated chunk. This provides real-time feedback
|
||||
* to the user instead of waiting for the complete response.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $messages Chat messages.
|
||||
* @param array $options Additional options (model, max_tokens, etc.).
|
||||
* @param string $type Request type (planning or execution).
|
||||
* @param callable $callback Callback function( $chunk, $is_complete, $full_content ).
|
||||
* @return array|WP_Error Response array or WP_Error on failure.
|
||||
*/
|
||||
public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) {
|
||||
// Check API key.
|
||||
if ( empty( $this->api_key ) ) {
|
||||
return new WP_Error(
|
||||
'no_api_key',
|
||||
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$web_search_enabled = $this->web_search_enabled;
|
||||
if ( is_array( $options ) && array_key_exists( 'web_search_enabled', $options ) ) {
|
||||
$web_search_enabled = (bool) $options['web_search_enabled'];
|
||||
}
|
||||
$search_depth = $options['search_depth'] ?? $this->search_depth;
|
||||
$search_engine = $options['search_engine'] ?? $this->search_engine;
|
||||
|
||||
// Determine model based on type (6 models per model-preset-brief.md).
|
||||
$model = $this->get_model_for_type( $type, $options );
|
||||
|
||||
// Add :online suffix if web search is enabled (for planning or execution/chat).
|
||||
if ( $web_search_enabled ) {
|
||||
$model .= ':online';
|
||||
}
|
||||
|
||||
// Build request body.
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'stream' => true, // Enable streaming!
|
||||
'stream_options' => array(
|
||||
'include_usage' => true,
|
||||
),
|
||||
'usage' => array(
|
||||
'include' => true,
|
||||
),
|
||||
);
|
||||
|
||||
// Add optional parameters.
|
||||
if ( isset( $options['max_tokens'] ) ) {
|
||||
$body['max_tokens'] = $options['max_tokens'];
|
||||
}
|
||||
|
||||
if ( isset( $options['temperature'] ) ) {
|
||||
$body['temperature'] = $options['temperature'];
|
||||
}
|
||||
|
||||
// Add web search options if enabled.
|
||||
if ( $web_search_enabled ) {
|
||||
$body['plugins'] = array(
|
||||
array(
|
||||
'id' => 'web',
|
||||
'web_search_options' => array(
|
||||
'search_context_size' => $search_depth,
|
||||
'max_results' => 5,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// Set search engine if specified.
|
||||
if ( 'auto' !== $search_engine ) {
|
||||
$body['plugins'][0]['web_search_options']['engine'] = $search_engine;
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulators for content and usage
|
||||
$accumulated_content = '';
|
||||
$accumulated_usage = array();
|
||||
$buffer = ''; // Buffer for incomplete lines
|
||||
|
||||
// Wrapper callback to accumulate content and call user callback
|
||||
$accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, &$accumulated_usage, $callback ) {
|
||||
if ( ! $is_complete && ! empty( $chunk ) ) {
|
||||
$accumulated_content .= $chunk;
|
||||
}
|
||||
|
||||
// Call user callback if provided
|
||||
if ( $callback ) {
|
||||
call_user_func( $callback, $chunk, $is_complete, $accumulated_content );
|
||||
}
|
||||
};
|
||||
|
||||
// Use cURL for streaming support (wp_remote_post doesn't support streaming)
|
||||
$ch = curl_init( $this->api_endpoint );
|
||||
|
||||
$json_body = wp_json_encode( $body );
|
||||
|
||||
// Set up cURL options with write function
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) {
|
||||
// Append new data to buffer
|
||||
$buffer .= $data;
|
||||
|
||||
// Process all complete lines
|
||||
while ( true ) {
|
||||
$newline_pos = strpos( $buffer, "\n" );
|
||||
if ( false === $newline_pos ) {
|
||||
// No complete lines, wait for more data
|
||||
break;
|
||||
}
|
||||
|
||||
// Extract one line
|
||||
$line = substr( $buffer, 0, $newline_pos );
|
||||
$buffer = substr( $buffer, $newline_pos + 1 );
|
||||
|
||||
$line = trim( $line );
|
||||
if ( empty( $line ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! 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 );
|
||||
}
|
||||
|
||||
// Accumulate usage data from final chunk
|
||||
if ( isset( $chunk['usage'] ) ) {
|
||||
$accumulated_usage = $chunk['usage'];
|
||||
}
|
||||
}
|
||||
|
||||
return strlen( $data );
|
||||
},
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Authorization: Bearer ' . $this->api_key,
|
||||
'Content-Type: application/json',
|
||||
'HTTP-Referer: ' . home_url(),
|
||||
'X-Title: WP Agentic Writer',
|
||||
),
|
||||
CURLOPT_POSTFIELDS => $json_body,
|
||||
CURLOPT_TIMEOUT => 180, // 3 minutes timeout for slower models
|
||||
) );
|
||||
|
||||
// Execute request
|
||||
$result = curl_exec( $ch );
|
||||
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
||||
$curl_error = curl_error( $ch );
|
||||
curl_close( $ch );
|
||||
|
||||
// Check for errors
|
||||
if ( $result === false && ! empty( $curl_error ) ) {
|
||||
return new WP_Error(
|
||||
'curl_error',
|
||||
__( 'cURL error: ', 'wp-agentic-writer' ) . $curl_error
|
||||
);
|
||||
}
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
return new WP_Error(
|
||||
'api_error',
|
||||
sprintf( __( 'API error: HTTP %d', 'wp-agentic-writer' ), $http_code )
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate cost from usage data
|
||||
$input_tokens = $accumulated_usage['prompt_tokens'] ?? 0;
|
||||
$output_tokens = $accumulated_usage['completion_tokens'] ?? 0;
|
||||
$cost = $accumulated_usage['cost'] ?? 0.0;
|
||||
|
||||
return array(
|
||||
'content' => $accumulated_content,
|
||||
'input_tokens' => $input_tokens,
|
||||
'output_tokens' => $output_tokens,
|
||||
'total_tokens' => $input_tokens + $output_tokens,
|
||||
'cost' => $cost,
|
||||
'model' => $model,
|
||||
'web_search_results' => array(), // Streaming doesn't return web search results
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $prompt Image prompt.
|
||||
* @return array|WP_Error Response array with image URL or WP_Error on failure.
|
||||
*/
|
||||
public function generate_image( $prompt ) {
|
||||
// Check API key.
|
||||
if ( empty( $this->api_key ) ) {
|
||||
return new WP_Error(
|
||||
'no_api_key',
|
||||
__( 'OpenRouter API key is not configured.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => sprintf(
|
||||
'Generate an image based on this prompt: %s. Return only the image URL.',
|
||||
$prompt
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
$response = $this->chat( $messages, array( 'model' => $this->image_model ), 'image' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Extract image URL from response.
|
||||
$content = $response['content'];
|
||||
$url = '';
|
||||
|
||||
// Try to extract URL from content.
|
||||
if ( preg_match( '/https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp)/i', $content, $matches ) ) {
|
||||
$url = $matches[0];
|
||||
}
|
||||
|
||||
return array(
|
||||
'url' => $url,
|
||||
'prompt' => $prompt,
|
||||
'cost' => $response['cost'],
|
||||
'model' => $response['model'],
|
||||
);
|
||||
}
|
||||
}
|
||||
1154
includes/class-settings-v2.php
Normal file
1154
includes/class-settings-v2.php
Normal file
File diff suppressed because it is too large
Load Diff
1551
includes/class-settings.php
Normal file
1551
includes/class-settings.php
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user