first commit all files

This commit is contained in:
dwindown
2026-01-28 00:26:00 +07:00
parent 65dd207a74
commit 97426d5ab1
72 changed files with 91484 additions and 0 deletions

View 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
}
}

View 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();

View 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,
);
}
}

File diff suppressed because it is too large Load Diff

View 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.";
}
}

View 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( '&#8211;', '&#8212;', '&ndash;', '&mdash;', '&bull;' ),
'-',
$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,
);
}
}

View 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'],
);
}
}

File diff suppressed because it is too large Load Diff

1551
includes/class-settings.php Normal file

File diff suppressed because it is too large Load Diff