feat: consolidate docs, backend/session infra, and settings updates
This commit is contained in:
@@ -204,7 +204,7 @@ class WP_Agentic_Writer_Codex_Provider implements WP_Agentic_Writer_AI_Provider_
|
||||
$buffer = substr( $buffer, $newline_pos + 1 );
|
||||
|
||||
$line = trim( $line );
|
||||
if ( empty( $line ) || ! str_starts_with( $line, 'data: ' ) ) {
|
||||
if ( empty( $line ) || 0 !== strpos( $line, 'data: ' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
425
includes/class-context-service.php
Normal file
425
includes/class-context-service.php
Normal file
@@ -0,0 +1,425 @@
|
||||
<?php
|
||||
/**
|
||||
* Context Service
|
||||
*
|
||||
* Centralized service for managing conversation context across
|
||||
* all generation paths. Provides a single source of truth for
|
||||
* chat history, plan, and per-post configuration.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Context_Service
|
||||
*
|
||||
* Single source of truth for conversation context.
|
||||
*
|
||||
* Storage Layer Rules:
|
||||
* - Conversation messages → wpaw_conversations table (authoritative)
|
||||
* - Article outline/plan → post_meta._wpaw_plan (authoritative)
|
||||
* - Per-post config → post_meta._wpaw_post_config (authoritative)
|
||||
* - Legacy _wpaw_chat_history → migrated to session table on read
|
||||
*/
|
||||
class WP_Agentic_Writer_Context_Service {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Context_Service
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
* @return WP_Agentic_Writer_Context_Service
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*/
|
||||
private function __construct() {
|
||||
// Private constructor for singleton.
|
||||
}
|
||||
|
||||
/**
|
||||
* Get conversation context for a session.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param int $post_id Post ID (optional).
|
||||
* @return array Context data.
|
||||
*/
|
||||
public function get_context( $session_id, $post_id = 0 ) {
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
$session = $manager->get_session( $session_id );
|
||||
$effective_session_id = $session_id;
|
||||
|
||||
// Migrate legacy history on read if no session exists but post has legacy data.
|
||||
if ( ! $session && $post_id > 0 ) {
|
||||
$legacy_history = get_post_meta( $post_id, '_wpaw_chat_history', true );
|
||||
$is_migrated = get_post_meta( $post_id, '_wpaw_chat_history_migrated', true );
|
||||
|
||||
if ( ! empty( $legacy_history ) && empty( $is_migrated ) ) {
|
||||
$migrated_session_id = $this->migrate_legacy_chat_history( $post_id, $session_id );
|
||||
// Use the session_id returned from migration (may be newly created)
|
||||
$effective_session_id = is_string( $migrated_session_id ) ? $migrated_session_id : $session_id;
|
||||
$session = $manager->get_session( $effective_session_id );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! $session ) {
|
||||
return $this->get_empty_context( $effective_session_id, $post_id );
|
||||
}
|
||||
|
||||
// If post_id is provided, get post-specific data
|
||||
$post_data = array();
|
||||
if ( $post_id > 0 ) {
|
||||
$post_data = $this->get_post_context( $post_id );
|
||||
}
|
||||
|
||||
return array(
|
||||
'session_id' => $effective_session_id,
|
||||
'post_id' => $post_id,
|
||||
'messages' => $session['messages'] ?? array(),
|
||||
'context' => $session['context'] ?? array(),
|
||||
'plan' => $post_data['plan'] ?? null,
|
||||
'post_config' => $post_data['post_config'] ?? $this->get_default_post_config(),
|
||||
'title' => $session['title'] ?? '',
|
||||
'focus_keyword' => $session['focus_keyword'] ?? '',
|
||||
'status' => $session['status'] ?? 'active',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get empty context structure.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param int $post_id Post ID.
|
||||
* @return array Empty context.
|
||||
*/
|
||||
private function get_empty_context( $session_id, $post_id ) {
|
||||
return array(
|
||||
'session_id' => $session_id,
|
||||
'post_id' => $post_id,
|
||||
'messages' => array(),
|
||||
'context' => array(),
|
||||
'plan' => null,
|
||||
'post_config' => $this->get_default_post_config(),
|
||||
'title' => '',
|
||||
'focus_keyword' => '',
|
||||
'status' => 'active',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post-specific context (plan, config).
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array Post context.
|
||||
*/
|
||||
public function get_post_context( $post_id ) {
|
||||
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
|
||||
if ( ! is_array( $plan ) ) {
|
||||
$plan = null;
|
||||
}
|
||||
|
||||
$post_config = get_post_meta( $post_id, '_wpaw_post_config', true );
|
||||
if ( ! is_array( $post_config ) ) {
|
||||
$post_config = $this->get_default_post_config();
|
||||
}
|
||||
|
||||
return array(
|
||||
'plan' => $plan,
|
||||
'post_config' => $post_config,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save messages to session.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param array $messages Messages array.
|
||||
* @return bool Success.
|
||||
*/
|
||||
public function save_messages( $session_id, $messages ) {
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
return $manager->update_messages( $session_id, $messages );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the session.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param array $message Message data.
|
||||
* @return bool Success.
|
||||
*/
|
||||
public function add_message( $session_id, $message ) {
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
$session = $manager->get_session( $session_id );
|
||||
|
||||
if ( ! $session ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$messages = $session['messages'] ?? array();
|
||||
$messages[] = $message;
|
||||
|
||||
return $manager->update_messages( $session_id, $messages );
|
||||
}
|
||||
|
||||
/**
|
||||
* Save plan to post meta.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $plan Plan data.
|
||||
* @return bool Success.
|
||||
*/
|
||||
public function save_plan( $post_id, $plan ) {
|
||||
if ( $post_id <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return update_post_meta( $post_id, '_wpaw_plan', $plan ) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get plan from post meta.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array|null Plan or null.
|
||||
*/
|
||||
public function get_plan( $post_id ) {
|
||||
if ( $post_id <= 0 ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plan = get_post_meta( $post_id, '_wpaw_plan', true );
|
||||
return is_array( $plan ) ? $plan : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save post config to post meta.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param array $post_config Post config.
|
||||
* @return bool Success.
|
||||
*/
|
||||
public function save_post_config( $post_id, $post_config ) {
|
||||
if ( $post_id <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return update_post_meta( $post_id, '_wpaw_post_config', $post_config ) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get post config from post meta.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array Post config.
|
||||
*/
|
||||
public function get_post_config( $post_id ) {
|
||||
if ( $post_id <= 0 ) {
|
||||
return $this->get_default_post_config();
|
||||
}
|
||||
|
||||
$config = get_post_meta( $post_id, '_wpaw_post_config', true );
|
||||
return is_array( $config ) ? wp_parse_args( $config, $this->get_default_post_config() ) : $this->get_default_post_config();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default post config.
|
||||
*
|
||||
* @return array Default config.
|
||||
*/
|
||||
public function get_default_post_config() {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
|
||||
return array(
|
||||
'article_length' => 'medium',
|
||||
'language' => 'auto',
|
||||
'tone' => '',
|
||||
'audience' => '',
|
||||
'experience_level' => 'general',
|
||||
'include_images' => true,
|
||||
'web_search' => false,
|
||||
'default_mode' => 'writing',
|
||||
'seo_focus_keyword' => '',
|
||||
'seo_secondary_keywords' => '',
|
||||
'seo_meta_description' => '',
|
||||
'seo_enabled' => false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy chat history from post meta to session.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $session_id Optional session ID to use. If not provided and no
|
||||
* session exists, creates a new one.
|
||||
* @return string|false Session ID used for migration, or false on failure.
|
||||
*/
|
||||
public function migrate_legacy_chat_history( $post_id, $session_id = '' ) {
|
||||
if ( $post_id <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$legacy_history = get_post_meta( $post_id, '_wpaw_chat_history', true );
|
||||
if ( empty( $legacy_history ) || ! is_array( $legacy_history ) ) {
|
||||
return $session_id ?: true; // Nothing to migrate, return existing or true
|
||||
}
|
||||
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
// Try to find existing session for this post if no session_id provided
|
||||
$sessions = array();
|
||||
if ( empty( $session_id ) ) {
|
||||
$sessions = $manager->get_sessions_for_post( $post_id );
|
||||
}
|
||||
|
||||
if ( ! empty( $sessions ) ) {
|
||||
// Append to existing session
|
||||
$session = $sessions[0];
|
||||
$existing_messages = $session['messages'] ?? array();
|
||||
|
||||
// Merge legacy messages (avoid duplicates based on content hash)
|
||||
$existing_hashes = array();
|
||||
foreach ( $existing_messages as $msg ) {
|
||||
$existing_hashes[] = $this->get_message_hash( $msg );
|
||||
}
|
||||
|
||||
foreach ( $legacy_history as $msg ) {
|
||||
$hash = $this->get_message_hash( $msg );
|
||||
if ( ! in_array( $hash, $existing_hashes, true ) ) {
|
||||
$existing_messages[] = $msg;
|
||||
}
|
||||
}
|
||||
|
||||
$manager->update_messages( $session['session_id'], $existing_messages );
|
||||
$migrated_session_id = $session['session_id'];
|
||||
} else {
|
||||
// Create new session with legacy messages
|
||||
$new_session_id = $manager->create_session( array(
|
||||
'post_id' => $post_id,
|
||||
'messages' => $legacy_history,
|
||||
'title' => 'Migrated from legacy',
|
||||
) );
|
||||
$migrated_session_id = is_string( $new_session_id ) ? $new_session_id : ( ! empty( $session_id ) ? $session_id : '' );
|
||||
}
|
||||
|
||||
// Delete legacy meta after successful migration and mark as migrated.
|
||||
delete_post_meta( $post_id, '_wpaw_chat_history' );
|
||||
update_post_meta( $post_id, '_wpaw_chat_history_migrated', current_time( 'mysql' ) );
|
||||
|
||||
return $migrated_session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hash for message deduplication.
|
||||
*
|
||||
* @param array $message Message.
|
||||
* @return string Hash.
|
||||
*/
|
||||
private function get_message_hash( $message ) {
|
||||
$content = $message['content'] ?? '';
|
||||
$role = $message['role'] ?? '';
|
||||
return md5( $role . ':' . $content );
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear context for a session and post.
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param int $post_id Post ID.
|
||||
* @return bool Success.
|
||||
*/
|
||||
public function clear_context( $session_id, $post_id = 0 ) {
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
|
||||
// Clear specific session if provided
|
||||
if ( $session_id ) {
|
||||
$manager->update_messages( $session_id, array() );
|
||||
} elseif ( $post_id > 0 ) {
|
||||
// No session_id provided - clear all active sessions for this post
|
||||
$sessions = $manager->get_sessions_for_post( $post_id );
|
||||
foreach ( $sessions as $session ) {
|
||||
if ( ! empty( $session['session_id'] ) && 'active' === ( $session['status'] ?? '' ) ) {
|
||||
$manager->update_messages( $session['session_id'], array() );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear post meta if post_id provided
|
||||
if ( $post_id > 0 ) {
|
||||
delete_post_meta( $post_id, '_wpaw_plan' );
|
||||
delete_post_meta( $post_id, '_wpaw_memory' );
|
||||
delete_post_meta( $post_id, '_wpaw_chat_history' );
|
||||
// Keep _wpaw_post_config as it's user settings
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context summary for display.
|
||||
*
|
||||
* @param array $context Context data.
|
||||
* @return string Human-readable summary.
|
||||
*/
|
||||
public function get_context_summary( $context ) {
|
||||
$parts = array();
|
||||
|
||||
// Message count
|
||||
$msg_count = count( $context['messages'] ?? array() );
|
||||
$parts[] = sprintf( _n( '%d message', '%d messages', $msg_count, 'wp-agentic-writer' ), $msg_count );
|
||||
|
||||
// Plan status
|
||||
if ( ! empty( $context['plan'] ) ) {
|
||||
$sections = count( $context['plan']['sections'] ?? array() );
|
||||
$parts[] = sprintf( _n( '%d section in plan', '%d sections in plan', $sections, 'wp-agentic-writer' ), $sections );
|
||||
} else {
|
||||
$parts[] = 'No plan';
|
||||
}
|
||||
|
||||
// Focus keyword
|
||||
if ( ! empty( $context['focus_keyword'] ) ) {
|
||||
$parts[] = 'Focus: ' . $context['focus_keyword'];
|
||||
}
|
||||
|
||||
return implode( ' | ', $parts );
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context for AI prompt.
|
||||
*
|
||||
* @param array $context Context data.
|
||||
* @param int $max_messages Maximum messages to include.
|
||||
* @return array Messages for AI.
|
||||
*/
|
||||
public function build_ai_context( $context, $max_messages = 20 ) {
|
||||
$messages = $context['messages'] ?? array();
|
||||
|
||||
// Limit to most recent messages
|
||||
if ( count( $messages ) > $max_messages ) {
|
||||
$messages = array_slice( $messages, -$max_messages );
|
||||
}
|
||||
|
||||
// Add context summary if available
|
||||
$context_summary = array(
|
||||
'role' => 'system',
|
||||
'content' => 'Current context: ' . $this->get_context_summary( $context ),
|
||||
);
|
||||
|
||||
return array_merge( array( $context_summary ), $messages );
|
||||
}
|
||||
}
|
||||
556
includes/class-conversation-manager.php
Normal file
556
includes/class-conversation-manager.php
Normal file
@@ -0,0 +1,556 @@
|
||||
<?php
|
||||
/**
|
||||
* Conversation Manager
|
||||
*
|
||||
* Handles session-based chat history with MySQL table storage.
|
||||
* Supports both post-linked and standalone sessions.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Conversation_Manager {
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
*
|
||||
* @var WP_Agentic_Writer_Conversation_Manager
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Database table name
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $table_name;
|
||||
|
||||
/**
|
||||
* Current session ID
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $current_session_id = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*
|
||||
* @return WP_Agentic_Writer_Conversation_Manager
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
private function __construct() {
|
||||
global $wpdb;
|
||||
$this->table_name = $wpdb->prefix . 'wpaw_conversations';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function generate_session_id() {
|
||||
return substr( md5( uniqid( wp_rand(), true ) ), 0, 16 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new conversation session
|
||||
*
|
||||
* @param array $data Session data.
|
||||
* @return string|WP_Error Session ID or error.
|
||||
*/
|
||||
public function create_session( $data = array() ) {
|
||||
global $wpdb;
|
||||
|
||||
$session_id = $this->generate_session_id();
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
$result = $wpdb->insert(
|
||||
$this->table_name,
|
||||
array(
|
||||
'session_id' => $session_id,
|
||||
'user_id' => $user_id,
|
||||
'post_id' => isset( $data['post_id'] ) ? (int) $data['post_id'] : 0,
|
||||
'title' => isset( $data['title'] ) ? sanitize_text_field( $data['title'] ) : '',
|
||||
'focus_keyword' => isset( $data['focus_keyword'] ) ? sanitize_text_field( $data['focus_keyword'] ) : '',
|
||||
'messages' => isset( $data['messages'] ) ? json_encode( $data['messages'] ) : '[]',
|
||||
'context' => isset( $data['context'] ) ? json_encode( $data['context'] ) : '{}',
|
||||
'status' => 'active',
|
||||
),
|
||||
array( '%s', '%d', '%d', '%s', '%s', '%s', '%s', '%s' )
|
||||
);
|
||||
|
||||
if ( false === $result ) {
|
||||
return new WP_Error(
|
||||
'db_error',
|
||||
__( 'Failed to create session.', 'wp-agentic-writer' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
$this->current_session_id = $session_id;
|
||||
return $session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current user can access a session
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @return bool True if user can access.
|
||||
*/
|
||||
public function current_user_can_access( $session_id ) {
|
||||
$session = $this->get_session( $session_id );
|
||||
|
||||
if ( ! $session ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$current_user_id = get_current_user_id();
|
||||
|
||||
// User owns this session
|
||||
if ( (int) $session['user_id'] === $current_user_id ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For post-linked sessions, check if user can edit the post
|
||||
if ( ! empty( $session['post_id'] ) ) {
|
||||
$post_id = (int) $session['post_id'];
|
||||
if ( $post_id > 0 && current_user_can( 'edit_post', $post_id ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by session ID (with authorization check)
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @return array|null Session data or null if not found/not authorized.
|
||||
*/
|
||||
public function get_session( $session_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$session = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$this->table_name} WHERE session_id = %s",
|
||||
$session_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $session ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode JSON fields
|
||||
$session['messages'] = json_decode( $session['messages'], true ) ?: array();
|
||||
$session['context'] = json_decode( $session['context'], true ) ?: array();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a session by session ID (public - for internal use only)
|
||||
* Use this only when authorization is handled separately
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @return array|null Session data or null if not found.
|
||||
*/
|
||||
public function get_session_unchecked( $session_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$session = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$this->table_name} WHERE session_id = %s",
|
||||
$session_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $session ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Decode JSON fields
|
||||
$session['messages'] = json_decode( $session['messages'], true ) ?: array();
|
||||
$session['context'] = json_decode( $session['context'], true ) ?: array();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session by post ID
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array|null Session data or null.
|
||||
*/
|
||||
public function get_session_by_post_id( $post_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$session = $wpdb->get_row(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$this->table_name} WHERE post_id = %d AND status = 'active' ORDER BY updated_at DESC LIMIT 1",
|
||||
$post_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
if ( ! $session ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$session['messages'] = json_decode( $session['messages'], true ) ?: array();
|
||||
$session['context'] = json_decode( $session['context'], true ) ?: array();
|
||||
|
||||
return $session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active sessions for current user
|
||||
*
|
||||
* @param string $status Status filter (active, completed, archived).
|
||||
* @param int $limit Number of results.
|
||||
* @return array Sessions list.
|
||||
*/
|
||||
public function get_user_sessions( $status = 'active', $limit = 20 ) {
|
||||
global $wpdb;
|
||||
$user_id = get_current_user_id();
|
||||
$posts_table = $wpdb->posts;
|
||||
|
||||
$sessions = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT c.id, c.session_id, c.post_id, c.title, c.focus_keyword, c.status, c.created_at, c.updated_at,
|
||||
JSON_LENGTH(c.messages) as message_count,
|
||||
COALESCE(p.post_status, '') as post_status
|
||||
FROM {$this->table_name} c
|
||||
LEFT JOIN {$posts_table} p ON p.ID = c.post_id
|
||||
WHERE c.user_id = %d AND c.status = %s
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT %d",
|
||||
$user_id,
|
||||
$status,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $sessions ?: array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all uncompleted sessions (post_id = 0) for current user
|
||||
*
|
||||
* @param int $limit Number of results.
|
||||
* @return array Sessions list.
|
||||
*/
|
||||
public function get_uncompleted_sessions( $limit = 20 ) {
|
||||
global $wpdb;
|
||||
$user_id = get_current_user_id();
|
||||
|
||||
$sessions = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, session_id, post_id, title, focus_keyword, status, created_at, updated_at,
|
||||
JSON_LENGTH(messages) as message_count
|
||||
FROM {$this->table_name}
|
||||
WHERE user_id = %d AND post_id = 0 AND status = 'active'
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT %d",
|
||||
$user_id,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $sessions ?: array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session messages
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param array $messages Messages array.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_messages( $session_id, $messages ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table_name,
|
||||
array(
|
||||
'messages' => json_encode( $messages ),
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session context
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param array $context Context data.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_context( $session_id, $context ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table_name,
|
||||
array(
|
||||
'context' => json_encode( $context ),
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link session to a post
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param int $post_id Post ID.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function link_to_post( $session_id, $post_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table_name,
|
||||
array(
|
||||
'post_id' => (int) $post_id,
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%d', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update session title
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param string $title New title.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_title( $session_id, $title ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table_name,
|
||||
array(
|
||||
'title' => sanitize_text_field( $title ),
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update focus keyword
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @param string $focus_keyword Focus keyword.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function update_focus_keyword( $session_id, $focus_keyword ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table_name,
|
||||
array(
|
||||
'focus_keyword' => sanitize_text_field( $focus_keyword ),
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark session as completed
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function mark_completed( $session_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->update(
|
||||
$this->table_name,
|
||||
array(
|
||||
'status' => 'completed',
|
||||
'updated_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%s', '%s' ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function delete_session( $session_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$result = $wpdb->delete(
|
||||
$this->table_name,
|
||||
array( 'session_id' => $session_id ),
|
||||
array( '%s' )
|
||||
);
|
||||
|
||||
return false !== $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create session for post
|
||||
*
|
||||
* @param int $post_id Post ID (can be 0 for new posts).
|
||||
* @return array Session data with session_id.
|
||||
*/
|
||||
public function get_or_create_session_for_post( $post_id = 0 ) {
|
||||
// Try to find existing session for this post
|
||||
if ( $post_id > 0 ) {
|
||||
$session = $this->get_session_by_post_id( $post_id );
|
||||
if ( $session ) {
|
||||
return $session;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session
|
||||
$session_id = $this->create_session( array( 'post_id' => $post_id ) );
|
||||
|
||||
if ( is_wp_error( $session_id ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->get_session( $session_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current session ID
|
||||
*
|
||||
* @param string $session_id Session ID.
|
||||
*/
|
||||
public function set_current_session( $session_id ) {
|
||||
$this->current_session_id = $session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session ID
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function get_current_session_id() {
|
||||
return $this->current_session_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session data
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function get_current_session() {
|
||||
if ( ! $this->current_session_id ) {
|
||||
return null;
|
||||
}
|
||||
return $this->get_session( $this->current_session_id );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if current session has post ID
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function current_session_has_post() {
|
||||
$session = $this->get_current_session();
|
||||
return $session && $session['post_id'] > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if editor has content (for auto-save decision)
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return bool True if post has content blocks.
|
||||
*/
|
||||
public function post_has_content( $post_id ) {
|
||||
if ( $post_id <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$post = get_post( $post_id );
|
||||
if ( ! $post ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if post has any blocks or content
|
||||
$blocks = parse_blocks( $post->post_content );
|
||||
return ! empty( $blocks );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all sessions for a specific post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array Sessions array.
|
||||
*/
|
||||
public function get_sessions_for_post( $post_id ) {
|
||||
global $wpdb;
|
||||
|
||||
if ( $post_id <= 0 ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$sessions = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT * FROM {$this->table_name} WHERE post_id = %d ORDER BY updated_at DESC",
|
||||
$post_id
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Decode JSON fields for each session
|
||||
foreach ( $sessions as &$session ) {
|
||||
$session['messages'] = json_decode( $session['messages'], true ) ?: array();
|
||||
$session['context'] = json_decode( $session['context'], true ) ?: array();
|
||||
}
|
||||
|
||||
return $sessions ?: array();
|
||||
}
|
||||
}
|
||||
99
includes/class-conversation-migration.php
Normal file
99
includes/class-conversation-migration.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
/**
|
||||
* Database migration for conversations table
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the conversations table
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_create_conversations_table() {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_conversations';
|
||||
$charset_collate = $wpdb->get_charset_collate();
|
||||
|
||||
$sql = "CREATE TABLE IF NOT EXISTS {$table_name} (
|
||||
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||
session_id VARCHAR(32) NOT NULL UNIQUE,
|
||||
user_id BIGINT NOT NULL,
|
||||
post_id BIGINT DEFAULT 0,
|
||||
title VARCHAR(255) DEFAULT '',
|
||||
focus_keyword VARCHAR(255) DEFAULT '',
|
||||
messages LONGTEXT,
|
||||
context LONGTEXT,
|
||||
status ENUM('active', 'completed', 'archived') DEFAULT 'active',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_status (user_id, status),
|
||||
INDEX idx_post_id (post_id),
|
||||
INDEX idx_session_id (session_id)
|
||||
) {$charset_collate};";
|
||||
|
||||
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
|
||||
dbDelta( $sql );
|
||||
|
||||
// Store conversation table migration version (don't override main db version)
|
||||
$existing_version = get_option( 'wpaw_conversations_db_version', '0' );
|
||||
if ( version_compare( $existing_version, '0.1.4', '<' ) ) {
|
||||
update_option( 'wpaw_conversations_db_version', '0.1.4' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the conversations table (for testing/reset)
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_drop_conversations_table() {
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'wpaw_conversations';
|
||||
$wpdb->query( "DROP TABLE IF EXISTS {$table_name}" );
|
||||
delete_option( 'wpaw_conversations_db_version' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run migrations on plugin activation
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_run_migrations() {
|
||||
$current_version = get_option( 'wpaw_conversations_db_version', '0' );
|
||||
|
||||
if ( version_compare( $current_version, '0.1.4', '<' ) ) {
|
||||
wpaw_create_conversations_table();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup old orphaned sessions (cron job)
|
||||
* Archives sessions inactive for 30+ days
|
||||
*
|
||||
* @since 0.1.4
|
||||
*/
|
||||
function wpaw_cleanup_old_sessions() {
|
||||
global $wpdb;
|
||||
$table_name = $wpdb->prefix . 'wpaw_conversations';
|
||||
|
||||
// Archive sessions with no post_id and inactive for 30 days
|
||||
$wpdb->query(
|
||||
$wpdb->prepare(
|
||||
"UPDATE {$table_name} SET status = 'archived' WHERE status = 'active' AND post_id = 0 AND updated_at < %s",
|
||||
date( 'Y-m-d H:i:s', strtotime( '-30 days' ) )
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Register cron job for cleanup
|
||||
add_action( 'wpaw_cleanup_old_sessions', 'wpaw_cleanup_old_sessions' );
|
||||
|
||||
if ( ! wp_next_scheduled( 'wpaw_cleanup_old_sessions' ) ) {
|
||||
wp_schedule_event( time(), 'daily', 'wpaw_cleanup_old_sessions' );
|
||||
}
|
||||
@@ -18,6 +18,14 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
*/
|
||||
class WP_Agentic_Writer_Cost_Tracker {
|
||||
|
||||
/**
|
||||
* Singleton instance.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @var WP_Agentic_Writer_Cost_Tracker
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
@@ -25,13 +33,11 @@ class WP_Agentic_Writer_Cost_Tracker {
|
||||
* @return WP_Agentic_Writer_Cost_Tracker
|
||||
*/
|
||||
public static function get_instance() {
|
||||
static $instance = null;
|
||||
|
||||
if ( null === $instance ) {
|
||||
$instance = new self();
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
|
||||
return $instance;
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -40,22 +46,82 @@ class WP_Agentic_Writer_Cost_Tracker {
|
||||
* @since 0.1.0
|
||||
*/
|
||||
private function __construct() {
|
||||
// Hooks for tracking costs.
|
||||
add_action( 'wp_aw_after_api_request', array( $this, 'add_request' ), 10, 6 );
|
||||
// Hooks for tracking costs - accept 9 args (provider, session_id, status added).
|
||||
add_action( 'wp_aw_after_api_request', array( $this, 'add_request' ), 10, 9 );
|
||||
|
||||
// Ensure table has new columns on first access.
|
||||
$this->maybe_upgrade_table();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure table has latest schema with provider/session/status columns.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*/
|
||||
private function maybe_upgrade_table() {
|
||||
global $wpdb;
|
||||
static $checked = false;
|
||||
|
||||
if ( $checked ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
|
||||
// Check if table exists first.
|
||||
$table_exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table_name ) );
|
||||
if ( ! $table_exists ) {
|
||||
// Table missing - trigger recreation.
|
||||
if ( function_exists( 'wp_agentic_writer_create_cost_table' ) ) {
|
||||
wp_agentic_writer_create_cost_table();
|
||||
}
|
||||
$checked = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if new columns exist.
|
||||
$columns = $wpdb->get_col( "DESCRIBE {$table_name}", 0 );
|
||||
|
||||
$needs_provider = ! in_array( 'provider', $columns, true );
|
||||
$needs_session = ! in_array( 'session_id', $columns, true );
|
||||
$needs_status = ! in_array( 'status', $columns, true );
|
||||
|
||||
if ( $needs_provider || $needs_session || $needs_status ) {
|
||||
$alter_parts = array();
|
||||
|
||||
if ( $needs_provider ) {
|
||||
$alter_parts[] = "ADD COLUMN provider varchar(50) DEFAULT 'openrouter' AFTER action";
|
||||
}
|
||||
if ( $needs_session ) {
|
||||
$alter_parts[] = "ADD COLUMN session_id varchar(32) DEFAULT '' AFTER post_id";
|
||||
}
|
||||
if ( $needs_status ) {
|
||||
$alter_parts[] = "ADD COLUMN status varchar(20) DEFAULT 'success' AFTER cost";
|
||||
}
|
||||
|
||||
if ( ! empty( $alter_parts ) ) {
|
||||
$wpdb->query( "ALTER TABLE {$table_name} " . implode( ', ', $alter_parts ) );
|
||||
}
|
||||
}
|
||||
|
||||
$checked = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add API request to cost tracking.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @since 0.2.0 Parameters changed: added provider, session_id, status.
|
||||
* @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.
|
||||
* @param string $provider Provider name (optional, defaults to 'unknown').
|
||||
* @param string $session_id Session ID (optional).
|
||||
* @param string $status Request status (optional, defaults to 'success').
|
||||
*/
|
||||
public function add_request( $post_id, $model, $action, $input_tokens, $output_tokens, $cost ) {
|
||||
public function add_request( $post_id, $model, $action, $input_tokens, $output_tokens, $cost, $provider = 'unknown', $session_id = '', $status = 'success' ) {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
@@ -64,14 +130,90 @@ class WP_Agentic_Writer_Cost_Tracker {
|
||||
$table_name,
|
||||
array(
|
||||
'post_id' => $post_id,
|
||||
'session_id' => $session_id,
|
||||
'model' => $model,
|
||||
'provider' => $provider,
|
||||
'action' => $action,
|
||||
'input_tokens' => $input_tokens,
|
||||
'output_tokens' => $output_tokens,
|
||||
'cost' => $cost,
|
||||
'status' => $status,
|
||||
'created_at' => current_time( 'mysql' ),
|
||||
),
|
||||
array( '%d', '%s', '%s', '%d', '%d', '%f', '%s' )
|
||||
array( '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%f', '%s', '%s' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy add_request for backward compatibility (4 params).
|
||||
*
|
||||
* @deprecated 0.2.0 Use add_request with all parameters.
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $model Model name.
|
||||
* @param string $action Action type.
|
||||
* @param int $input_tokens Input tokens.
|
||||
* @param int $output_tokens Output tokens.
|
||||
* @param float $cost Cost in USD.
|
||||
*/
|
||||
public function add_request_legacy( $post_id, $model, $action, $input_tokens, $output_tokens, $cost ) {
|
||||
$this->add_request( $post_id, $model, $action, $input_tokens, $output_tokens, $cost );
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage from WP AI Client wrapper (legacy contract).
|
||||
*
|
||||
* This method provides backward compatibility for the WP AI Client wrapper
|
||||
* and other callers that use a simpler interface.
|
||||
*
|
||||
* @deprecated 0.1.4 Use record_usage_full() instead for accurate provider attribution.
|
||||
* @since 0.1.3
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $action Action/task type (e.g., 'chat', 'planning', 'writing').
|
||||
* @param string $model Model identifier.
|
||||
* @param float $cost Cost in USD.
|
||||
* @param string $session_id Session ID (optional).
|
||||
*/
|
||||
public function record_usage( $post_id, $action, $model, $cost, $session_id = '' ) {
|
||||
$this->record_usage_full(
|
||||
$post_id,
|
||||
$model,
|
||||
$action,
|
||||
0, // input_tokens - not available in wrapper
|
||||
0, // output_tokens - not available in wrapper
|
||||
$cost,
|
||||
'unknown', // deprecated wrapper - provider unknown
|
||||
$session_id,
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record usage with full metadata.
|
||||
*
|
||||
* Use this method when you have complete information about the request.
|
||||
*
|
||||
* @since 0.1.4
|
||||
* @param int $post_id Post ID.
|
||||
* @param string $model Model identifier.
|
||||
* @param string $action Action/task type.
|
||||
* @param int $input_tokens Input token count.
|
||||
* @param int $output_tokens Output token count.
|
||||
* @param float $cost Cost in USD.
|
||||
* @param string $provider Provider name.
|
||||
* @param string $session_id Session ID.
|
||||
* @param string $status Request status.
|
||||
*/
|
||||
public function record_usage_full( $post_id, $model, $action, $input_tokens, $output_tokens, $cost, $provider, $session_id = '', $status = 'success' ) {
|
||||
$this->add_request(
|
||||
$post_id,
|
||||
$model,
|
||||
$action,
|
||||
$input_tokens,
|
||||
$output_tokens,
|
||||
$cost,
|
||||
$provider,
|
||||
$session_id,
|
||||
$status
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,7 +236,7 @@ class WP_Agentic_Writer_Cost_Tracker {
|
||||
)
|
||||
);
|
||||
|
||||
return floatval( $total );
|
||||
return floatval( $total ) + $this->get_image_variants_total_for_post( $post_id );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,15 +249,16 @@ class WP_Agentic_Writer_Cost_Tracker {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_cost_tracking';
|
||||
$month_start = date( 'Y-m-01 00:00:00' );
|
||||
|
||||
$total = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(cost) FROM {$table_name} WHERE created_at >= %s",
|
||||
date( 'Y-m-01 00:00:00' )
|
||||
$month_start
|
||||
)
|
||||
);
|
||||
|
||||
return floatval( $total );
|
||||
return floatval( $total ) + $this->get_image_variants_total_since( $month_start );
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,6 +296,15 @@ class WP_Agentic_Writer_Cost_Tracker {
|
||||
$total_tokens += intval( $row['tokens'] );
|
||||
}
|
||||
|
||||
$image_cost = $this->get_image_variants_total_since( date( 'Y-m-d 00:00:00' ) );
|
||||
if ( $image_cost > 0 ) {
|
||||
$usage['image_generation'] = array(
|
||||
'tokens' => 0,
|
||||
'cost' => $image_cost,
|
||||
);
|
||||
$total_cost += $image_cost;
|
||||
}
|
||||
|
||||
$usage['total'] = array(
|
||||
'cost' => $total_cost,
|
||||
'tokens' => $total_tokens,
|
||||
@@ -205,7 +357,129 @@ class WP_Agentic_Writer_Cost_Tracker {
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return $results;
|
||||
$history = $results ?: array();
|
||||
$image_history = $this->get_image_variants_history_for_post( $post_id, $limit );
|
||||
$history = array_merge( $history, $image_history );
|
||||
|
||||
usort(
|
||||
$history,
|
||||
function( $a, $b ) {
|
||||
return strcmp( $b['created_at'] ?? '', $a['created_at'] ?? '' );
|
||||
}
|
||||
);
|
||||
|
||||
return array_slice( $history, 0, $limit );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a table exists.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @param string $table_name Table name.
|
||||
* @return bool
|
||||
*/
|
||||
private function table_exists( $table_name ) {
|
||||
global $wpdb;
|
||||
return (bool) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table_name ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image variant generation total for a post.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @param int $post_id Post ID.
|
||||
* @return float
|
||||
*/
|
||||
private function get_image_variants_total_for_post( $post_id ) {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_images_variants';
|
||||
if ( $post_id <= 0 || ! $this->table_exists( $table_name ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$total = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(cost) FROM {$table_name} WHERE post_id = %d",
|
||||
$post_id
|
||||
)
|
||||
);
|
||||
|
||||
return floatval( $total );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image variant generation total since a timestamp.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @param string $since MySQL datetime.
|
||||
* @return float
|
||||
*/
|
||||
private function get_image_variants_total_since( $since ) {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_images_variants';
|
||||
if ( ! $this->table_exists( $table_name ) ) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$total = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(cost) FROM {$table_name} WHERE created_at >= %s",
|
||||
$since
|
||||
)
|
||||
);
|
||||
|
||||
return floatval( $total );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image variant generation history for a post in cost-history shape.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @param int $post_id Post ID.
|
||||
* @param int $limit Limit.
|
||||
* @return array
|
||||
*/
|
||||
private function get_image_variants_history_for_post( $post_id, $limit = 50 ) {
|
||||
global $wpdb;
|
||||
|
||||
$table_name = $wpdb->prefix . 'wpaw_images_variants';
|
||||
if ( $post_id <= 0 || ! $this->table_exists( $table_name ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$rows = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT id, post_id, image_model_used, cost, generation_time, status, created_at
|
||||
FROM {$table_name}
|
||||
WHERE post_id = %d
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %d",
|
||||
$post_id,
|
||||
$limit
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
return array_map(
|
||||
function( $row ) {
|
||||
return array(
|
||||
'id' => 'image_variant_' . ( $row['id'] ?? '' ),
|
||||
'post_id' => (int) ( $row['post_id'] ?? 0 ),
|
||||
'session_id' => '',
|
||||
'model' => $row['image_model_used'] ?? '',
|
||||
'provider' => 'openrouter',
|
||||
'action' => 'image_generation',
|
||||
'input_tokens' => 0,
|
||||
'output_tokens' => 0,
|
||||
'cost' => (float) ( $row['cost'] ?? 0 ),
|
||||
'status' => $row['status'] ?? '',
|
||||
'created_at' => $row['created_at'] ?? '',
|
||||
);
|
||||
},
|
||||
$rows ?: array()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -43,6 +43,41 @@ class WP_Agentic_Writer_Image_Manager {
|
||||
// Private constructor for singleton.
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if required tables exist.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return bool True if tables exist, false otherwise.
|
||||
*/
|
||||
public function tables_exist() {
|
||||
global $wpdb;
|
||||
$table_images = $wpdb->prefix . 'wpaw_images';
|
||||
|
||||
// Check if table exists using SHOW TABLES
|
||||
$result = $wpdb->get_var( "SHOW TABLES LIKE '{$table_images}'" );
|
||||
|
||||
return $result === $table_images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure tables exist, create if missing.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return true|WP_Error True on success, WP_Error on failure.
|
||||
*/
|
||||
public function ensure_tables() {
|
||||
if ( ! $this->tables_exist() ) {
|
||||
$result = $this->create_tables();
|
||||
if ( ! $result ) {
|
||||
return new WP_Error(
|
||||
'table_creation_failed',
|
||||
__( 'Failed to create image database tables. Please check database permissions.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create database tables on plugin activation.
|
||||
*/
|
||||
@@ -110,6 +145,8 @@ class WP_Agentic_Writer_Image_Manager {
|
||||
|
||||
// Create temp directory.
|
||||
$this->create_temp_directory();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,7 +182,7 @@ class WP_Agentic_Writer_Image_Manager {
|
||||
*/
|
||||
public function analyze_article_for_images( $article_markdown, $post_id ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$writing_model = $settings['writing_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$writing_model = $settings['writing_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' );
|
||||
|
||||
$system_prompt = "You are an expert content strategist analyzing articles for optimal image placement.
|
||||
|
||||
@@ -178,7 +215,8 @@ Return JSON:
|
||||
),
|
||||
);
|
||||
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
$provider = $provider_result->provider;
|
||||
$response = $provider->chat( $messages, array( 'temperature' => 0.3 ), 'planning' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
@@ -207,8 +245,8 @@ Return JSON:
|
||||
*/
|
||||
public function generate_image_prompts( $article_markdown, $placement_data, $post_id ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$writing_model = $settings['writing_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
$writing_model = $settings['writing_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' );
|
||||
$image_model = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' );
|
||||
|
||||
// Get model-specific prompt guidance.
|
||||
$prompt_guidance = $this->get_prompt_guidance_for_model( $image_model );
|
||||
@@ -255,7 +293,8 @@ Return JSON:
|
||||
),
|
||||
);
|
||||
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
|
||||
$provider = $provider_result->provider;
|
||||
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
@@ -318,6 +357,13 @@ Return JSON:
|
||||
* @param array $images Image specifications.
|
||||
*/
|
||||
private function save_image_recommendations( $post_id, $images ) {
|
||||
// Ensure tables exist before saving
|
||||
$check = $this->ensure_tables();
|
||||
if ( is_wp_error( $check ) ) {
|
||||
error_log( 'WPAW Image Manager: Cannot save recommendations - tables not available' );
|
||||
return;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpaw_images';
|
||||
|
||||
@@ -348,14 +394,20 @@ Return JSON:
|
||||
* @param string $section_title Section title.
|
||||
* @param string $prompt Image prompt/description.
|
||||
* @param string $alt_text Alt text for image.
|
||||
* @return int|false Insert ID or false on failure.
|
||||
* @return int|false|WP_Error Insert ID, false on failure, or WP_Error if tables don't exist.
|
||||
*/
|
||||
public function save_image_recommendation( $post_id, $agent_image_id, $placement, $section_title, $prompt, $alt_text ) {
|
||||
// Ensure tables exist before saving
|
||||
$check = $this->ensure_tables();
|
||||
if ( is_wp_error( $check ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpaw_images';
|
||||
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
$image_model = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' );
|
||||
|
||||
$result = $wpdb->insert(
|
||||
$table,
|
||||
@@ -383,9 +435,15 @@ Return JSON:
|
||||
* Get image recommendations for a post.
|
||||
*
|
||||
* @param int $post_id Post ID.
|
||||
* @return array Image recommendations.
|
||||
* @return array|WP_Error Image recommendations or error if tables don't exist.
|
||||
*/
|
||||
public function get_image_recommendations( $post_id ) {
|
||||
// Ensure tables exist before querying
|
||||
$check = $this->ensure_tables();
|
||||
if ( is_wp_error( $check ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
global $wpdb;
|
||||
$table = $wpdb->prefix . 'wpaw_images';
|
||||
|
||||
@@ -410,10 +468,17 @@ Return JSON:
|
||||
* @return array|WP_Error Generated variants or error.
|
||||
*/
|
||||
public function generate_image_variants( $post_id, $agent_image_id, $prompt, $variant_count = 2 ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
// Ensure tables exist before proceeding
|
||||
$check = $this->ensure_tables();
|
||||
if ( is_wp_error( $check ) ) {
|
||||
return $check;
|
||||
}
|
||||
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'image' );
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$image_model = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' );
|
||||
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'image' );
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
$variants = array();
|
||||
|
||||
@@ -480,20 +545,36 @@ Return JSON:
|
||||
wp_mkdir_p( $temp_dir );
|
||||
}
|
||||
|
||||
// Download image.
|
||||
$response = wp_remote_get( $image_url, array( 'timeout' => 30 ) );
|
||||
$image_data = '';
|
||||
$extension = 'jpg';
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
if ( preg_match( '#^data:image/([a-zA-Z0-9.+-]+);base64,(.+)$#', (string) $image_url, $matches ) ) {
|
||||
$extension = strtolower( $matches[1] );
|
||||
$extension = 'jpeg' === $extension ? 'jpg' : $extension;
|
||||
$image_data = base64_decode( $matches[2] );
|
||||
if ( false === $image_data ) {
|
||||
return new WP_Error(
|
||||
'invalid_image_data',
|
||||
__( 'Generated image data could not be decoded.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Download image.
|
||||
$response = wp_remote_get( $image_url, array( 'timeout' => 30 ) );
|
||||
|
||||
$image_data = wp_remote_retrieve_body( $response );
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Determine file extension from content type.
|
||||
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
|
||||
$extension = 'jpg';
|
||||
if ( strpos( $content_type, 'png' ) !== false ) {
|
||||
$extension = 'png';
|
||||
$image_data = wp_remote_retrieve_body( $response );
|
||||
|
||||
// Determine file extension from content type.
|
||||
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
|
||||
if ( strpos( $content_type, 'png' ) !== false ) {
|
||||
$extension = 'png';
|
||||
} elseif ( strpos( $content_type, 'webp' ) !== false ) {
|
||||
$extension = 'webp';
|
||||
}
|
||||
}
|
||||
|
||||
$filename = sprintf(
|
||||
@@ -591,19 +672,37 @@ Return JSON:
|
||||
return new WP_Error( 'variant_not_found', 'Variant not found' );
|
||||
}
|
||||
|
||||
if ( empty( $variant['temp_file_path'] ) || ! file_exists( $variant['temp_file_path'] ) ) {
|
||||
return new WP_Error(
|
||||
'variant_file_missing',
|
||||
__( 'Generated image file is missing. Please generate the variant again.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
// Upload to Media Library.
|
||||
require_once ABSPATH . 'wp-admin/includes/image.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/media.php';
|
||||
|
||||
$sideload_tmp = wp_tempnam( basename( $variant['temp_file_path'] ) );
|
||||
if ( ! $sideload_tmp || ! copy( $variant['temp_file_path'], $sideload_tmp ) ) {
|
||||
return new WP_Error(
|
||||
'variant_copy_failed',
|
||||
__( 'Generated image could not be prepared for upload.', 'wp-agentic-writer' )
|
||||
);
|
||||
}
|
||||
|
||||
$file_array = array(
|
||||
'name' => basename( $variant['temp_file_path'] ),
|
||||
'tmp_name' => $variant['temp_file_path'],
|
||||
'tmp_name' => $sideload_tmp,
|
||||
);
|
||||
|
||||
$attachment_id = media_handle_sideload( $file_array, $post_id );
|
||||
|
||||
if ( is_wp_error( $attachment_id ) ) {
|
||||
if ( file_exists( $sideload_tmp ) ) {
|
||||
@unlink( $sideload_tmp );
|
||||
}
|
||||
return $attachment_id;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ class WP_Agentic_Writer_Keyword_Suggester {
|
||||
* @return array|WP_Error Array with focus_keyword, secondary_keywords, reasoning, and cost.
|
||||
*/
|
||||
public static function suggest_keywords( $title, $sections, $language = 'english', $post_id = 0 ) {
|
||||
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' );
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
// Build outline text from sections
|
||||
$outline_text = '';
|
||||
@@ -114,9 +115,28 @@ class WP_Agentic_Writer_Keyword_Suggester {
|
||||
$suggestions['secondary_keywords'] = array();
|
||||
}
|
||||
|
||||
// Track cost with separate operation type
|
||||
// Track cost with full nine-argument contract including provider attribution.
|
||||
$cost = $response['cost'] ?? 0;
|
||||
if ( $cost > 0 && $post_id > 0 ) {
|
||||
$actual_provider = 'unknown';
|
||||
$provider_name = '';
|
||||
|
||||
// Extract provider info from provider_result.
|
||||
if ( is_object( $provider_result ) && isset( $provider_result->actual_provider ) ) {
|
||||
$actual_provider = $provider_result->actual_provider;
|
||||
$provider_name = is_object( $provider ) ? get_class( $provider ) : 'unknown';
|
||||
}
|
||||
|
||||
// Get session ID for this post if available.
|
||||
$session_id = '';
|
||||
if ( $post_id > 0 ) {
|
||||
$manager = WP_Agentic_Writer_Conversation_Manager::get_instance();
|
||||
$session = $manager->get_session_by_post_id( $post_id );
|
||||
if ( $session && isset( $session['session_id'] ) ) {
|
||||
$session_id = $session['session_id'];
|
||||
}
|
||||
}
|
||||
|
||||
do_action(
|
||||
'wp_aw_after_api_request',
|
||||
$post_id,
|
||||
@@ -124,7 +144,10 @@ class WP_Agentic_Writer_Keyword_Suggester {
|
||||
'suggest_keyword',
|
||||
$response['input_tokens'] ?? 0,
|
||||
$response['output_tokens'] ?? 0,
|
||||
$cost
|
||||
$cost,
|
||||
$actual_provider,
|
||||
$session_id,
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -133,6 +156,8 @@ class WP_Agentic_Writer_Keyword_Suggester {
|
||||
'secondary_keywords' => $suggestions['secondary_keywords'],
|
||||
'reasoning' => $suggestions['reasoning'] ?? '',
|
||||
'cost' => $cost,
|
||||
'provider_result' => $provider_result,
|
||||
'model' => $response['model'] ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
|
||||
$code = wp_remote_retrieve_response_code( $response );
|
||||
if ( 200 !== $code ) {
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
error_log( '[WPAW] Local backend HTTP error: ' . $code . ', body: ' . substr( $body, 0, 500 ) );
|
||||
return new WP_Error(
|
||||
'api_error',
|
||||
sprintf(
|
||||
@@ -108,7 +109,10 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
|
||||
error_log( '[WPAW] Local backend response keys: ' . implode( ', ', is_array( $body ) ? array_keys( $body ) : array( 'not_array' ) ) );
|
||||
|
||||
if ( ! isset( $body['choices'][0]['message']['content'] ) ) {
|
||||
error_log( '[WPAW] Local backend response: ' . wp_json_encode( $body ) );
|
||||
return new WP_Error(
|
||||
'invalid_response',
|
||||
__( 'Invalid response format from Local Backend', 'wp-agentic-writer' )
|
||||
@@ -166,6 +170,23 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
|
||||
|
||||
$ch = curl_init( $this->base_url . '/v1/messages' );
|
||||
|
||||
$headers = array(
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->api_key,
|
||||
);
|
||||
|
||||
// Add search headers if web search is enabled
|
||||
if ( ! empty( $options['web_search_enabled'] ) ) {
|
||||
$headers[] = 'X-Search-Enabled: true';
|
||||
// Extract last user message as search query
|
||||
foreach ( array_reverse( $messages ) as $msg ) {
|
||||
if ( 'user' === $msg['role'] ) {
|
||||
$headers[] = 'X-Search-Query: ' . substr( $msg['content'], 0, 500 );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_RETURNTRANSFER => false,
|
||||
@@ -184,7 +205,7 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
|
||||
$buffer = substr( $buffer, $newline_pos + 1 );
|
||||
|
||||
$line = trim( $line );
|
||||
if ( empty( $line ) || ! str_starts_with( $line, 'data: ' ) ) {
|
||||
if ( empty( $line ) || 0 !== strpos( $line, 'data: ' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -213,10 +234,7 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
|
||||
|
||||
return strlen( $data );
|
||||
},
|
||||
CURLOPT_HTTPHEADER => array(
|
||||
'Content-Type: application/json',
|
||||
'Authorization: Bearer ' . $this->api_key,
|
||||
),
|
||||
CURLOPT_HTTPHEADER => $headers,
|
||||
CURLOPT_POSTFIELDS => wp_json_encode( $body ),
|
||||
CURLOPT_TIMEOUT => 300,
|
||||
) );
|
||||
@@ -235,7 +253,8 @@ class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_P
|
||||
}
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
return new WP_Error( 'api_error', sprintf( 'API error (%d): %s', $http_code, $buffer ) );
|
||||
error_log( 'WPAW Local Backend API error: HTTP=' . $http_code . ', Buffer: ' . substr( $buffer, 0, 1000 ) );
|
||||
return new WP_Error( 'api_error', sprintf( 'API error (%d): %s', $http_code, substr( $buffer, 0, 500 ) ) );
|
||||
}
|
||||
|
||||
// FALLBACK: If no SSE chunks were parsed, the proxy likely returned a plain JSON response.
|
||||
|
||||
@@ -35,6 +35,7 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$in_list = false;
|
||||
$list_items = array();
|
||||
$list_type = 'ul'; // 'ul' or 'ol'
|
||||
$list_start = null;
|
||||
$code_lines = array();
|
||||
$code_language = '';
|
||||
$in_auto_code_block = false;
|
||||
@@ -89,9 +90,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
|
||||
// Get agent_image_id from placeholders array if available
|
||||
@@ -115,9 +117,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
|
||||
// Create button block.
|
||||
@@ -134,9 +137,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
|
||||
$auto_code_language = preg_match( '/^(<\\?php|define\\s*\\(|\\$[A-Za-z_])/', $trimmed ) ? 'php' : 'text';
|
||||
@@ -162,9 +166,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
$in_code_block = true;
|
||||
$code_language = $matches[1];
|
||||
@@ -186,9 +191,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
}
|
||||
// Flush any pending list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
|
||||
$level = strlen( $matches[1] );
|
||||
@@ -206,9 +212,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
|
||||
$headers = self::split_table_row( $trimmed );
|
||||
@@ -235,9 +242,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
// Gutenberg doesn't have a native separator block, use a spacer.
|
||||
$blocks[] = array(
|
||||
@@ -258,11 +266,14 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list && $list_type !== 'ul' ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
$in_list = true;
|
||||
$list_type = 'ul';
|
||||
$list_start = null;
|
||||
$list_items[] = self::parse_inline_markdown( $matches[1] );
|
||||
continue;
|
||||
}
|
||||
@@ -274,9 +285,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
// Create paragraph with manual numbering and bold title.
|
||||
$content = $matches[1] . '. <strong>' . self::parse_inline_markdown( $matches[2] ) . '</strong>';
|
||||
@@ -285,18 +297,23 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
}
|
||||
|
||||
// Handle ordered lists.
|
||||
if ( preg_match( '/^\d+\.\s+(.+)$/', $trimmed, $matches ) ) {
|
||||
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 );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
if ( ! $in_list ) {
|
||||
$list_start = (int) $matches[1];
|
||||
}
|
||||
$in_list = true;
|
||||
$list_type = 'ol';
|
||||
$list_items[] = self::parse_inline_markdown( $matches[1] );
|
||||
$list_items[] = self::parse_inline_markdown( $matches[2] );
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -307,9 +324,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$current_paragraph = '';
|
||||
}
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
$blocks[] = self::create_quote_block( $matches[1] );
|
||||
continue;
|
||||
@@ -324,9 +342,10 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
}
|
||||
// Flush list.
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
$list_items = array();
|
||||
$in_list = false;
|
||||
$list_start = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -354,7 +373,7 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
$blocks[] = self::create_paragraph_block( $current_paragraph );
|
||||
}
|
||||
if ( $in_list ) {
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items );
|
||||
$blocks[] = self::create_list_block( $list_type, $list_items, $list_start );
|
||||
}
|
||||
|
||||
// Merge consecutive ordered lists (fix 1. 1. 1. issue)
|
||||
@@ -597,11 +616,15 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
* @since 0.1.0
|
||||
* @param string $type List type ('ul' or 'ol').
|
||||
* @param array $items List items.
|
||||
* @param int|null $start Ordered list start value.
|
||||
* @return array Gutenberg block.
|
||||
*/
|
||||
private static function create_list_block( $type, $items ) {
|
||||
private static function create_list_block( $type, $items, $start = null ) {
|
||||
$tag = $type === 'ol' ? 'ol' : 'ul';
|
||||
$html = '<' . $tag . '>';
|
||||
$is_ordered = 'ol' === $tag;
|
||||
$start = $is_ordered ? max( 1, (int) $start ) : null;
|
||||
$start_attr = $is_ordered && $start > 1 ? ' start="' . $start . '"' : '';
|
||||
$html = '<' . $tag . $start_attr . '>';
|
||||
|
||||
// Create inner blocks for each list item
|
||||
$inner_blocks = array();
|
||||
@@ -627,11 +650,17 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
|
||||
$html .= '</' . $tag . '>';
|
||||
|
||||
$attrs = array(
|
||||
'ordered' => $is_ordered,
|
||||
);
|
||||
|
||||
if ( $is_ordered && $start > 1 ) {
|
||||
$attrs['start'] = $start;
|
||||
}
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/list',
|
||||
'attrs' => array(
|
||||
'ordered' => $type === 'ol',
|
||||
),
|
||||
'attrs' => $attrs,
|
||||
'innerBlocks' => $inner_blocks,
|
||||
'innerContent' => $inner_content,
|
||||
'innerHTML' => $html,
|
||||
@@ -735,7 +764,8 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
} 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 );
|
||||
$start = isset( $pending_ol['attrs']['start'] ) ? (int) $pending_ol['attrs']['start'] : 1;
|
||||
$result[] = self::rebuild_ordered_list( $pending_ol_items, $start );
|
||||
$pending_ol = null;
|
||||
$pending_ol_items = array();
|
||||
}
|
||||
@@ -745,7 +775,8 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
|
||||
// Flush any remaining ordered list
|
||||
if ( null !== $pending_ol && ! empty( $pending_ol_items ) ) {
|
||||
$result[] = self::rebuild_ordered_list( $pending_ol_items );
|
||||
$start = isset( $pending_ol['attrs']['start'] ) ? (int) $pending_ol['attrs']['start'] : 1;
|
||||
$result[] = self::rebuild_ordered_list( $pending_ol_items, $start );
|
||||
}
|
||||
|
||||
return $result;
|
||||
@@ -756,10 +787,13 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param array $items List item blocks.
|
||||
* @param int $start Ordered list start value.
|
||||
* @return array Ordered list block.
|
||||
*/
|
||||
private static function rebuild_ordered_list( $items ) {
|
||||
$html = '<ol>';
|
||||
private static function rebuild_ordered_list( $items, $start = 1 ) {
|
||||
$start = max( 1, (int) $start );
|
||||
$start_attr = $start > 1 ? ' start="' . $start . '"' : '';
|
||||
$html = '<ol' . $start_attr . '>';
|
||||
$inner_content = array();
|
||||
|
||||
foreach ( $items as $item ) {
|
||||
@@ -770,11 +804,17 @@ class WP_Agentic_Writer_Markdown_Parser {
|
||||
|
||||
$html .= '</ol>';
|
||||
|
||||
$attrs = array(
|
||||
'ordered' => true,
|
||||
);
|
||||
|
||||
if ( $start > 1 ) {
|
||||
$attrs['start'] = $start;
|
||||
}
|
||||
|
||||
return array(
|
||||
'blockName' => 'core/list',
|
||||
'attrs' => array(
|
||||
'ordered' => true,
|
||||
),
|
||||
'attrs' => $attrs,
|
||||
'innerBlocks' => $items,
|
||||
'innerContent' => $inner_content,
|
||||
'innerHTML' => $html,
|
||||
|
||||
261
includes/class-model-registry.php
Normal file
261
includes/class-model-registry.php
Normal file
@@ -0,0 +1,261 @@
|
||||
<?php
|
||||
/**
|
||||
* Model Registry
|
||||
*
|
||||
* Centralized source of truth for model defaults, labels,
|
||||
* capabilities, and provider support across the plugin.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Class WPAW_Model_Registry
|
||||
*
|
||||
* Single source of truth for model configuration.
|
||||
*
|
||||
* Usage:
|
||||
* $defaults = WPAW_Model_Registry::get_task_defaults();
|
||||
* $registry = WPAW_Model_Registry::get_registry();
|
||||
*/
|
||||
class WPAW_Model_Registry {
|
||||
|
||||
/**
|
||||
* Task type constants.
|
||||
*/
|
||||
const TASK_CHAT = 'chat';
|
||||
const TASK_CLARITY = 'clarity';
|
||||
const TASK_PLANNING = 'planning';
|
||||
const TASK_WRITING = 'writing';
|
||||
const TASK_EXECUTION = 'execution';
|
||||
const TASK_REFINEMENT = 'refinement';
|
||||
const TASK_ANALYSIS = 'analysis';
|
||||
const TASK_SUMMARIZE = 'summarize';
|
||||
const TASK_IMAGE = 'image';
|
||||
|
||||
/**
|
||||
* Get the complete model registry.
|
||||
*
|
||||
* Structure:
|
||||
* 'task_type' => [
|
||||
* 'default' => 'model_id',
|
||||
* 'fallback' => 'model_id', // optional
|
||||
* 'label' => 'Human-readable name',
|
||||
* 'description' => 'What this model is used for',
|
||||
* 'supported_providers' => ['openrouter', 'local', 'codex'], // optional
|
||||
* 'capabilities' => ['chat', 'streaming', 'vision'], // optional
|
||||
* ]
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Model registry.
|
||||
*/
|
||||
public static function get_registry() {
|
||||
return array(
|
||||
'chat' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'google/gemini-2.0-flash-exp',
|
||||
'label' => 'Chat Model',
|
||||
'description' => 'Discussion, research, and recommendations',
|
||||
'capabilities' => array( 'chat', 'streaming', 'reasoning' ),
|
||||
),
|
||||
'clarity' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'google/gemini-2.0-flash-exp',
|
||||
'label' => 'Clarity Model',
|
||||
'description' => 'Prompt analysis and quiz generation',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'planning' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'google/gemini-2.0-flash-exp',
|
||||
'label' => 'Planning Model',
|
||||
'description' => 'Article outline and structure generation',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'writing' => array(
|
||||
'default' => 'anthropic/claude-3.5-haiku',
|
||||
'fallback' => 'google/gemini-2.5-flash',
|
||||
'label' => 'Writing Model',
|
||||
'description' => 'Article content generation',
|
||||
'capabilities' => array( 'chat', 'streaming', 'long_context' ),
|
||||
),
|
||||
'execution' => array(
|
||||
'default' => 'anthropic/claude-3.5-haiku',
|
||||
'fallback' => 'google/gemini-2.5-flash',
|
||||
'label' => 'Execution Model',
|
||||
'description' => 'Article section writing (alias for writing)',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'refinement' => array(
|
||||
'default' => 'anthropic/claude-3.5-sonnet',
|
||||
'fallback' => 'anthropic/claude-3.5-haiku',
|
||||
'label' => 'Refinement Model',
|
||||
'description' => 'Paragraph edits, rewrites, and improvements',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'analysis' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'anthropic/claude-3.5-haiku',
|
||||
'label' => 'Analysis Model',
|
||||
'description' => 'Content analysis and improvement suggestions',
|
||||
'capabilities' => array( 'chat', 'streaming' ),
|
||||
),
|
||||
'summarize' => array(
|
||||
'default' => 'google/gemini-2.5-flash',
|
||||
'fallback' => 'anthropic/claude-3.5-haiku',
|
||||
'label' => 'Summarization Model',
|
||||
'description' => 'Context summarization and compression',
|
||||
'capabilities' => array( 'chat' ),
|
||||
),
|
||||
'image' => array(
|
||||
'default' => 'openai/gpt-4o',
|
||||
'fallback' => 'openai/dall-e-3',
|
||||
'label' => 'Image Generation Model',
|
||||
'description' => 'Image generation for articles',
|
||||
'capabilities' => array( 'image_generation' ),
|
||||
'supported_providers' => array( 'openrouter' ),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default model for a task type.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $task Task type (chat, planning, execution, etc).
|
||||
* @return string Default model ID.
|
||||
*/
|
||||
public static function get_default_model( $task ) {
|
||||
$registry = self::get_registry();
|
||||
$task_data = $registry[ $task ] ?? $registry['chat'];
|
||||
return $task_data['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fallback model for a task type.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $task Task type.
|
||||
* @return string Fallback model ID.
|
||||
*/
|
||||
public static function get_fallback_model( $task ) {
|
||||
$registry = self::get_registry();
|
||||
$task_data = $registry[ $task ] ?? $registry['chat'];
|
||||
return $task_data['fallback'] ?? $task_data['default'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all task defaults as key-value pairs.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Task => default_model pairs.
|
||||
*/
|
||||
public static function get_task_defaults() {
|
||||
$registry = self::get_registry();
|
||||
$defaults = array();
|
||||
|
||||
foreach ( $registry as $task => $data ) {
|
||||
$defaults[ $task ] = $data['default'];
|
||||
}
|
||||
|
||||
return $defaults;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get activation defaults (for plugin activation).
|
||||
*
|
||||
* This returns the format expected by the settings option.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Settings-compatible defaults.
|
||||
*/
|
||||
public static function get_activation_defaults() {
|
||||
return array(
|
||||
'planning_model' => self::get_default_model( 'planning' ),
|
||||
'execution_model' => self::get_default_model( 'writing' ),
|
||||
'image_model' => self::get_default_model( 'image' ),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a model ID is in the registry.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $model Model ID.
|
||||
* @return bool True if valid.
|
||||
*/
|
||||
public static function is_valid_model( $model ) {
|
||||
foreach ( self::get_registry() as $task_data ) {
|
||||
if ( $model === $task_data['default'] || $model === $task_data['fallback'] ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display name for a model ID.
|
||||
*
|
||||
* Extracts a human-readable name from model IDs like
|
||||
* "google/gemini-2.5-flash" -> "Google Gemini 2.5 Flash"
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param string $model_id Model ID.
|
||||
* @return string Human-readable display name.
|
||||
*/
|
||||
public static function get_model_display_name( $model_id ) {
|
||||
if ( empty( $model_id ) ) {
|
||||
return 'Unknown Model';
|
||||
}
|
||||
|
||||
// Handle known model ID patterns
|
||||
$display_names = array(
|
||||
'google/gemini-2.5-flash' => 'Google Gemini 2.5 Flash',
|
||||
'google/gemini-2.0-flash-exp' => 'Google Gemini 2.0 Flash',
|
||||
'google/gemini-2.0-flash-exp:free' => 'Google Gemini 2.0 Flash',
|
||||
'anthropic/claude-3.5-sonnet' => 'Anthropic Claude 3.5 Sonnet',
|
||||
'anthropic/claude-3.5-haiku' => 'Anthropic Claude 3.5 Haiku',
|
||||
'openai/gpt-4o' => 'OpenAI GPT-4o',
|
||||
'openai/dall-e-3' => 'OpenAI DALL-E 3',
|
||||
'black-forest-labs/flux-schnell' => 'Black Forest Flux Schnell',
|
||||
);
|
||||
|
||||
if ( isset( $display_names[ $model_id ] ) ) {
|
||||
return $display_names[ $model_id ];
|
||||
}
|
||||
|
||||
// Generate from model ID: "provider/model-name" -> "Provider Model Name"
|
||||
$parts = explode( '/', $model_id );
|
||||
if ( count( $parts ) >= 2 ) {
|
||||
$provider = ucfirst( str_replace( '-', ' ', $parts[0] ) );
|
||||
$name = ucwords( str_replace( '-', ' ', $parts[1] ) );
|
||||
return trim( $provider . ' ' . $name );
|
||||
}
|
||||
|
||||
return ucwords( str_replace( '-', ' ', $model_id ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get JavaScript-compatible registry for frontend.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array JS-safe registry data.
|
||||
*/
|
||||
public static function get_frontend_data() {
|
||||
$registry = self::get_registry();
|
||||
$result = array();
|
||||
|
||||
foreach ( $registry as $task => $data ) {
|
||||
$result[ $task ] = array(
|
||||
'default' => $data['default'],
|
||||
'fallback' => $data['fallback'] ?? null,
|
||||
'label' => $data['label'],
|
||||
);
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -28,45 +28,51 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
|
||||
/**
|
||||
* Chat model (discussion, research, recommendations).
|
||||
* Initialized from WPAW_Model_Registry in constructor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $chat_model = 'google/gemini-2.5-flash';
|
||||
private $chat_model = '';
|
||||
|
||||
/**
|
||||
* Clarity model (prompt analysis, quiz generation).
|
||||
* Initialized from WPAW_Model_Registry in constructor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $clarity_model = 'google/gemini-2.5-flash';
|
||||
private $clarity_model = '';
|
||||
|
||||
/**
|
||||
* Planning model (article outline generation).
|
||||
* Initialized from WPAW_Model_Registry in constructor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $planning_model = 'google/gemini-2.5-flash';
|
||||
private $planning_model = '';
|
||||
|
||||
/**
|
||||
* Writing model (article draft generation).
|
||||
* Initialized from WPAW_Model_Registry in constructor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $writing_model = 'anthropic/claude-3.5-sonnet';
|
||||
private $writing_model = '';
|
||||
|
||||
/**
|
||||
* Refinement model (paragraph edits, rewrites).
|
||||
* Initialized from WPAW_Model_Registry in constructor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $refinement_model = 'anthropic/claude-3.5-sonnet';
|
||||
private $refinement_model = '';
|
||||
|
||||
/**
|
||||
* Image model.
|
||||
* Initialized from WPAW_Model_Registry in constructor.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $image_model = 'openai/gpt-4o';
|
||||
private $image_model = '';
|
||||
|
||||
/**
|
||||
* Web search enabled.
|
||||
@@ -98,13 +104,15 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
|
||||
/**
|
||||
* Get cached models from OpenRouter API.
|
||||
* Stores full model objects in a separate transient from the ID list.
|
||||
*
|
||||
* @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' );
|
||||
// Check if we have cached models (full objects, not IDs).
|
||||
$cache_key = 'wpaw_openrouter_model_objects';
|
||||
$cached_models = get_transient( $cache_key );
|
||||
if ( false !== $cached_models ) {
|
||||
return $cached_models;
|
||||
}
|
||||
@@ -119,7 +127,7 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
|
||||
// Fetch all models from OpenRouter API.
|
||||
$response = wp_remote_get(
|
||||
'https://openrouter.ai/api/v1/models',
|
||||
'https://openrouter.ai/api/v1/models?output_modalities=all',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
@@ -146,13 +154,15 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
|
||||
// Debug: Log model count and categorize by output_modalities
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'OpenRouter API total models: ' . count( $models ) );
|
||||
|
||||
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 ) ) {
|
||||
@@ -163,13 +173,16 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
$image_model_ids[] = $model['id'] . ' (' . ( $model['name'] ?? 'N/A' ) . ')';
|
||||
}
|
||||
}
|
||||
|
||||
error_log( "OpenRouter models by output_modalities: TEXT={$text_count}, IMAGE={$image_count}" );
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
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 ) ) );
|
||||
}
|
||||
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 );
|
||||
// Cache for 24 hours - use separate key for objects.
|
||||
set_transient( $cache_key, $models, DAY_IN_SECONDS );
|
||||
|
||||
return $models;
|
||||
}
|
||||
@@ -183,7 +196,9 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
*/
|
||||
public function fetch_and_cache_models( $force_refresh = false ) {
|
||||
if ( $force_refresh ) {
|
||||
delete_transient( 'wpaw_openrouter_models' );
|
||||
// Delete both transient keys on refresh to ensure clean slate.
|
||||
delete_transient( 'wpaw_openrouter_model_objects' );
|
||||
delete_transient( 'wpaw_openrouter_model_ids' );
|
||||
}
|
||||
|
||||
return $this->get_cached_models();
|
||||
@@ -228,6 +243,277 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the model is available on OpenRouter before making API calls.
|
||||
* Uses a cached list of available model IDs to avoid repeated API calls.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $model Model ID to validate.
|
||||
* @return true|WP_Error True if valid, WP_Error if model unavailable.
|
||||
*/
|
||||
private function validate_model_availability( $model ) {
|
||||
// Strip :online suffix if present
|
||||
$base_model = trim( str_replace( ':online', '', (string) $model ) );
|
||||
if ( $this->is_custom_model_id( $base_model ) ) {
|
||||
// Custom models are user-managed. Skip strict pre-validation and let
|
||||
// OpenRouter return the authoritative runtime response.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get cached available model IDs (separate from full model objects).
|
||||
$cache_key = 'wpaw_openrouter_model_ids';
|
||||
$available_models = get_transient( $cache_key );
|
||||
|
||||
if ( false === $available_models ) {
|
||||
$available_models = $this->fetch_available_models();
|
||||
// Cache for 6 hours
|
||||
set_transient( $cache_key, $available_models, 6 * HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
// Normalize: if old transient exists with full objects instead of IDs,
|
||||
// extract just the IDs for safe comparison.
|
||||
$model_ids = $this->normalize_model_ids( $available_models );
|
||||
|
||||
// Check if model is in available list. If missing, force one fresh fetch
|
||||
// to avoid false negatives from stale cache.
|
||||
if ( ! in_array( $base_model, $model_ids, true ) ) {
|
||||
$refreshed_models = $this->fetch_available_models();
|
||||
if ( is_array( $refreshed_models ) && ! empty( $refreshed_models ) ) {
|
||||
set_transient( $cache_key, $refreshed_models, 6 * HOUR_IN_SECONDS );
|
||||
$model_ids = $this->normalize_model_ids( $refreshed_models );
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! in_array( $base_model, $model_ids, true ) ) {
|
||||
$suggestion = $this->get_model_suggestion( $base_model );
|
||||
$error_msg = sprintf(
|
||||
/* translators: %1$s: current model, %2$s: suggestion */
|
||||
__( 'Model "%1$s" is not available on OpenRouter. %2$s', 'wp-agentic-writer' ),
|
||||
$base_model,
|
||||
$suggestion
|
||||
);
|
||||
return new WP_Error(
|
||||
'model_unavailable',
|
||||
$error_msg,
|
||||
array(
|
||||
'status' => 400,
|
||||
'code' => 'MODEL_UNAVAILABLE',
|
||||
'current_model' => $base_model,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether model ID exists in user-defined custom models list.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @param string $model_id Model ID.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_custom_model_id( $model_id ) {
|
||||
$model_id = trim( (string) $model_id );
|
||||
if ( '' === $model_id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ( $this->get_custom_model_ids() as $custom_id ) {
|
||||
if ( 0 === strcasecmp( $custom_id, $model_id ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-defined custom model IDs.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @return array
|
||||
*/
|
||||
private function get_custom_model_ids() {
|
||||
$custom_models = get_option( 'wp_agentic_writer_custom_models', array() );
|
||||
if ( ! is_array( $custom_models ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$ids = array();
|
||||
foreach ( $custom_models as $custom ) {
|
||||
if ( ! is_array( $custom ) ) {
|
||||
continue;
|
||||
}
|
||||
$custom_id = isset( $custom['id'] ) ? trim( (string) $custom['id'] ) : '';
|
||||
if ( '' !== $custom_id ) {
|
||||
$ids[] = $custom_id;
|
||||
}
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build model availability trace for debugging runtime model selection.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @param string $model Model ID.
|
||||
* @return array
|
||||
*/
|
||||
private function build_model_trace( $model ) {
|
||||
$model = trim( str_replace( ':online', '', (string) $model ) );
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
|
||||
$cache_key = 'wpaw_openrouter_model_ids';
|
||||
$cached_models = get_transient( $cache_key );
|
||||
$cache_was_loaded = false !== $cached_models;
|
||||
$model_ids = $this->normalize_model_ids( $cached_models );
|
||||
$cache_has_model = in_array( $model, $model_ids, true );
|
||||
|
||||
$refreshed_has_model = null;
|
||||
if ( ! $cache_has_model ) {
|
||||
$refreshed_models = $this->fetch_available_models();
|
||||
if ( is_array( $refreshed_models ) && ! empty( $refreshed_models ) ) {
|
||||
set_transient( $cache_key, $refreshed_models, 6 * HOUR_IN_SECONDS );
|
||||
$refreshed_ids = $this->normalize_model_ids( $refreshed_models );
|
||||
$refreshed_has_model = in_array( $model, $refreshed_ids, true );
|
||||
}
|
||||
}
|
||||
|
||||
return array(
|
||||
'selected_model' => $model,
|
||||
'settings_image_model' => isset( $settings['image_model'] ) ? (string) $settings['image_model'] : '',
|
||||
'image_task_provider' => isset( $settings['task_providers']['image'] ) ? (string) $settings['task_providers']['image'] : 'openrouter',
|
||||
'custom_model_ids' => $this->get_custom_model_ids(),
|
||||
'custom_model_match' => $this->is_custom_model_id( $model ),
|
||||
'model_cache_loaded' => $cache_was_loaded,
|
||||
'model_cache_has_model' => $cache_has_model,
|
||||
'refreshed_has_model' => $refreshed_has_model,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize cached data to extract model IDs.
|
||||
* Handles backward compatibility for old transient data that may contain
|
||||
* full model objects instead of just IDs.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @param mixed $data Cached data (may be IDs array or full objects array).
|
||||
* @return array Normalized array of model ID strings.
|
||||
*/
|
||||
private function normalize_model_ids( $data ) {
|
||||
// If it's not an array, return empty
|
||||
if ( ! is_array( $data ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// If array is empty, return empty
|
||||
if ( empty( $data ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
// Check if it's an array of strings (already normalized) or objects
|
||||
$first_item = reset( $data );
|
||||
|
||||
if ( is_string( $first_item ) ) {
|
||||
// Already normalized - just IDs as strings
|
||||
return $data;
|
||||
}
|
||||
|
||||
if ( is_array( $first_item ) ) {
|
||||
// Old transient: array of model objects with 'id' key
|
||||
$ids = array();
|
||||
foreach ( $data as $item ) {
|
||||
if ( isset( $item['id'] ) && is_string( $item['id'] ) ) {
|
||||
$ids[] = $item['id'];
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
if ( is_object( $first_item ) ) {
|
||||
// Old transient: array of model objects with 'id' property
|
||||
$ids = array();
|
||||
foreach ( $data as $item ) {
|
||||
if ( isset( $item->id ) && is_string( $item->id ) ) {
|
||||
$ids[] = $item->id;
|
||||
}
|
||||
}
|
||||
return $ids;
|
||||
}
|
||||
|
||||
// Unknown format - return empty to force refresh
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available model IDs from OpenRouter API.
|
||||
* Caches only the IDs in a separate transient from full model objects.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @return array List of available model IDs.
|
||||
*/
|
||||
private function fetch_available_models() {
|
||||
$response = wp_remote_get(
|
||||
'https://openrouter.ai/api/v1/models?output_modalities=all',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
),
|
||||
'timeout' => 30,
|
||||
)
|
||||
);
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'WPAW: Failed to fetch OpenRouter models: ' . $response->get_error_message() );
|
||||
}
|
||||
return array();
|
||||
}
|
||||
|
||||
$body = wp_remote_retrieve_body( $response );
|
||||
$data = json_decode( $body, true );
|
||||
|
||||
if ( ! isset( $data['data'] ) || ! is_array( $data['data'] ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
$model_ids = array();
|
||||
foreach ( $data['data'] as $model ) {
|
||||
if ( isset( $model['id'] ) ) {
|
||||
$model_ids[] = $model['id'];
|
||||
}
|
||||
}
|
||||
|
||||
// Also flush old transient if it exists to prevent shape conflict.
|
||||
delete_transient( 'wpaw_openrouter_models' );
|
||||
|
||||
return $model_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a model suggestion based on the requested model.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param string $model Requested model ID.
|
||||
* @return string Suggestion message.
|
||||
*/
|
||||
private function get_model_suggestion( $model ) {
|
||||
$suggestions = array(
|
||||
'anthropic/claude-3.5-sonnet' => __( 'Try using "anthropic/claude-3.5-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
||||
'anthropic/claude-3.5-sonnet-v2' => __( 'Try using "anthropic/claude-3.5-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
||||
'anthropic/claude-3-opus' => __( 'Try using "anthropic/claude-3-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
||||
'anthropic/claude-3-sonnet' => __( 'Try using "anthropic/claude-3-haiku" instead, or go to Settings → Models to choose a different Writing model.', 'wp-agentic-writer' ),
|
||||
);
|
||||
|
||||
if ( isset( $suggestions[ $model ] ) ) {
|
||||
return $suggestions[ $model ];
|
||||
}
|
||||
|
||||
return __( 'Please go to Settings → Models and select a different model that is available on OpenRouter.', 'wp-agentic-writer' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get singleton instance.
|
||||
*
|
||||
@@ -254,13 +540,23 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$this->api_key = $settings['openrouter_api_key'] ?? '';
|
||||
|
||||
// Initialize model defaults from registry (set after settings to allow override).
|
||||
$registry_defaults = array(
|
||||
'chat_model' => WPAW_Model_Registry::get_default_model( 'chat' ),
|
||||
'clarity_model' => WPAW_Model_Registry::get_default_model( 'clarity' ),
|
||||
'planning_model' => WPAW_Model_Registry::get_default_model( 'planning' ),
|
||||
'writing_model' => WPAW_Model_Registry::get_default_model( 'writing' ),
|
||||
'refinement_model' => WPAW_Model_Registry::get_default_model( 'refinement' ),
|
||||
'image_model' => WPAW_Model_Registry::get_default_model( 'image' ),
|
||||
);
|
||||
|
||||
// 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;
|
||||
$this->chat_model = $settings['chat_model'] ?? $registry_defaults['chat_model'];
|
||||
$this->clarity_model = $settings['clarity_model'] ?? $registry_defaults['clarity_model'];
|
||||
$this->planning_model = $settings['planning_model'] ?? $registry_defaults['planning_model'];
|
||||
$this->writing_model = $settings['writing_model'] ?? $registry_defaults['writing_model'];
|
||||
$this->refinement_model = $settings['refinement_model'] ?? $registry_defaults['refinement_model'];
|
||||
$this->image_model = $settings['image_model'] ?? $registry_defaults['image_model'];
|
||||
|
||||
// Get web search settings.
|
||||
$this->web_search_enabled = isset( $settings['web_search_enabled'] ) && '1' === $settings['web_search_enabled'];
|
||||
@@ -438,6 +734,12 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
$model .= ':online';
|
||||
}
|
||||
|
||||
// Validate model availability before making API call
|
||||
$model_validation = $this->validate_model_availability( $model );
|
||||
if ( is_wp_error( $model_validation ) ) {
|
||||
return $model_validation;
|
||||
}
|
||||
|
||||
// Build request body.
|
||||
$body = array(
|
||||
'model' => $model,
|
||||
@@ -500,6 +802,10 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
|
||||
$json_body = wp_json_encode( $body );
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'WPAW OpenRouter request: model=' . $model . ', messages_count=' . count( $messages ) . ', first_msg_role=' . (isset( $messages[0]['role'] ) ? $messages[0]['role'] : 'N/A') );
|
||||
}
|
||||
|
||||
// Set up cURL options with write function
|
||||
curl_setopt_array( $ch, array(
|
||||
CURLOPT_POST => true,
|
||||
@@ -525,7 +831,7 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( ! str_starts_with( $line, 'data: ' ) ) {
|
||||
if ( 0 !== strpos( $line, 'data: ' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -566,6 +872,10 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
$curl_error = curl_error( $ch );
|
||||
curl_close( $ch );
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'WPAW OpenRouter response: HTTP=' . $http_code . ', curl_error=' . $curl_error . ', result_type=' . gettype( $result ) . ', buffer_len=' . strlen( $buffer ) . ', accumulated_content_len=' . strlen( $accumulated_content ) );
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
if ( $result === false && ! empty( $curl_error ) ) {
|
||||
return new WP_Error(
|
||||
@@ -575,12 +885,35 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
}
|
||||
|
||||
if ( $http_code >= 400 ) {
|
||||
// Try to extract error message from buffer
|
||||
$error_msg = 'API error';
|
||||
$buffer_content = trim( $buffer );
|
||||
if ( ! empty( $buffer_content ) ) {
|
||||
$error_data = json_decode( $buffer_content, true );
|
||||
if ( isset( $error_data['error']['message'] ) ) {
|
||||
$error_msg = $error_data['error']['message'];
|
||||
} elseif ( isset( $error_data['message'] ) ) {
|
||||
$error_msg = $error_data['message'];
|
||||
} else {
|
||||
$error_msg = $buffer_content;
|
||||
}
|
||||
}
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'WPAW OpenRouter API error: HTTP=' . $http_code . ', Buffer: ' . substr( $buffer_content, 0, 500 ) . ', Error: ' . $error_msg );
|
||||
}
|
||||
return new WP_Error(
|
||||
'api_error',
|
||||
sprintf( __( 'API error: HTTP %d', 'wp-agentic-writer' ), $http_code )
|
||||
sprintf( __( 'API error: HTTP %d - %s', 'wp-agentic-writer' ), $http_code, $error_msg )
|
||||
);
|
||||
}
|
||||
|
||||
// Log if content is unexpectedly empty
|
||||
if ( empty( $accumulated_content ) && ! empty( $buffer ) ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'WPAW OpenRouter: Empty content but buffer has data: ' . substr( trim( $buffer ), 0, 500 ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate cost from usage data
|
||||
$input_tokens = $accumulated_usage['prompt_tokens'] ?? 0;
|
||||
$output_tokens = $accumulated_usage['completion_tokens'] ?? 0;
|
||||
@@ -615,11 +948,41 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
$size = $options['size'] ?? '1024x576';
|
||||
$quality = $options['quality'] ?? 'hd';
|
||||
$n = $options['n'] ?? 1;
|
||||
$model_trace = $this->build_model_trace( $model );
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( 'WPAW image generation model trace: ' . wp_json_encode( $model_trace ) );
|
||||
}
|
||||
|
||||
$start_time = microtime( true );
|
||||
$image_config = array(
|
||||
'image_size' => '1K',
|
||||
);
|
||||
if ( false !== strpos( (string) $size, 'x' ) ) {
|
||||
$parts = array_map( 'intval', explode( 'x', (string) $size ) );
|
||||
if ( 2 === count( $parts ) && $parts[0] > 0 && $parts[1] > 0 ) {
|
||||
$ratio = $parts[0] / $parts[1];
|
||||
if ( $ratio > 1.6 && $ratio < 1.9 ) {
|
||||
$image_config['aspect_ratio'] = '16:9';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$request_body = array(
|
||||
'model' => $model,
|
||||
'messages' => array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
),
|
||||
),
|
||||
'modalities' => $this->get_image_generation_modalities( $model ),
|
||||
'image_config' => $image_config,
|
||||
'stream' => false,
|
||||
);
|
||||
|
||||
$response = wp_remote_post(
|
||||
'https://openrouter.ai/api/v1/images/generations',
|
||||
'https://openrouter.ai/api/v1/chat/completions',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
@@ -627,15 +990,7 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
'HTTP-Referer' => home_url(),
|
||||
'X-Title' => get_bloginfo( 'name' ),
|
||||
),
|
||||
'body' => wp_json_encode(
|
||||
array(
|
||||
'model' => $model,
|
||||
'prompt' => $prompt,
|
||||
'n' => $n,
|
||||
'size' => $size,
|
||||
'quality' => $quality,
|
||||
)
|
||||
),
|
||||
'body' => wp_json_encode( $request_body ),
|
||||
'timeout' => 60,
|
||||
)
|
||||
);
|
||||
@@ -643,20 +998,76 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
$generation_time = microtime( true ) - $start_time;
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
return new WP_Error(
|
||||
$response->get_error_code(),
|
||||
$response->get_error_message(),
|
||||
array(
|
||||
'status' => 500,
|
||||
'trace' => array_merge(
|
||||
$model_trace,
|
||||
array(
|
||||
'endpoint' => 'https://openrouter.ai/api/v1/chat/completions',
|
||||
'request_model' => $model,
|
||||
'request_size' => $size,
|
||||
'request_quality' => $quality,
|
||||
'request_n' => $n,
|
||||
'request_prompt_len' => strlen( (string) $prompt ),
|
||||
'transport_error' => $response->get_error_message(),
|
||||
)
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
||||
$raw_body = wp_remote_retrieve_body( $response );
|
||||
$body = json_decode( $raw_body, true );
|
||||
$http_code = wp_remote_retrieve_response_code( $response );
|
||||
$response_trace = array_merge(
|
||||
$model_trace,
|
||||
array(
|
||||
'endpoint' => 'https://openrouter.ai/api/v1/chat/completions',
|
||||
'request_model' => $model,
|
||||
'request_size' => $size,
|
||||
'request_quality' => $quality,
|
||||
'request_n' => $n,
|
||||
'request_modalities' => $request_body['modalities'],
|
||||
'request_image_config' => $request_body['image_config'],
|
||||
'request_prompt_len' => strlen( (string) $prompt ),
|
||||
'openrouter_http' => $http_code,
|
||||
'openrouter_response' => is_array( $body ) ? $body : substr( (string) $raw_body, 0, 2000 ),
|
||||
)
|
||||
);
|
||||
|
||||
if ( ! isset( $body['data'][0]['url'] ) ) {
|
||||
// Check for API errors
|
||||
if ( $http_code >= 400 ) {
|
||||
$error_msg = $body['error']['message'] ?? 'Image generation failed';
|
||||
return new WP_Error(
|
||||
'image_api_error',
|
||||
sprintf( __( 'Image generation failed (HTTP %d): %s', 'wp-agentic-writer' ), $http_code, $error_msg ),
|
||||
array(
|
||||
'status' => $http_code,
|
||||
'trace' => $response_trace,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$image_url = $body['choices'][0]['message']['images'][0]['image_url']['url']
|
||||
?? $body['choices'][0]['message']['images'][0]['imageUrl']['url']
|
||||
?? '';
|
||||
|
||||
if ( '' === $image_url ) {
|
||||
return new WP_Error(
|
||||
'image_generation_failed',
|
||||
$body['error']['message'] ?? 'Unknown error'
|
||||
$body['error']['message'] ?? 'Unknown error - no image URL returned',
|
||||
array(
|
||||
'status' => 502,
|
||||
'trace' => $response_trace,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return array(
|
||||
'url' => $body['data'][0]['url'],
|
||||
'url' => $image_url,
|
||||
'cost' => $body['usage']['cost'] ?? 0.03,
|
||||
'generation_time' => $generation_time,
|
||||
'model' => $model,
|
||||
@@ -664,6 +1075,38 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine OpenRouter modalities for an image generation model.
|
||||
*
|
||||
* @since 0.2.1
|
||||
* @param string $model Model ID.
|
||||
* @return array
|
||||
*/
|
||||
private function get_image_generation_modalities( $model ) {
|
||||
$model = trim( str_replace( ':online', '', (string) $model ) );
|
||||
$models = $this->get_cached_models();
|
||||
if ( ! is_wp_error( $models ) && is_array( $models ) ) {
|
||||
foreach ( $models as $entry ) {
|
||||
$id = is_array( $entry ) ? ( $entry['id'] ?? '' ) : ( $entry->id ?? '' );
|
||||
if ( 0 !== strcasecmp( (string) $id, $model ) ) {
|
||||
continue;
|
||||
}
|
||||
$architecture = is_array( $entry ) ? ( $entry['architecture'] ?? array() ) : (array) ( $entry->architecture ?? array() );
|
||||
$output_modalities = isset( $architecture['output_modalities'] ) && is_array( $architecture['output_modalities'] )
|
||||
? $architecture['output_modalities']
|
||||
: array();
|
||||
if ( in_array( 'image', $output_modalities, true ) && in_array( 'text', $output_modalities, true ) ) {
|
||||
return array( 'image', 'text' );
|
||||
}
|
||||
if ( in_array( 'image', $output_modalities, true ) ) {
|
||||
return array( 'image' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array( 'image' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if provider is configured
|
||||
*
|
||||
@@ -684,7 +1127,7 @@ class WP_Agentic_Writer_OpenRouter_Provider implements WP_Agentic_Writer_AI_Prov
|
||||
}
|
||||
|
||||
$response = wp_remote_get(
|
||||
'https://openrouter.ai/api/v1/models',
|
||||
'https://openrouter.ai/api/v1/models?output_modalities=all',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $this->api_key,
|
||||
|
||||
@@ -11,30 +11,85 @@ if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result object containing provider instance plus selection metadata.
|
||||
* Used to satisfy the DoD Provider Transparency contract.
|
||||
*
|
||||
* @since 0.2.0
|
||||
*/
|
||||
class WPAW_Provider_Selection_Result {
|
||||
public $provider; // Provider instance
|
||||
public $selected_provider; // Original requested provider name
|
||||
public $actual_provider; // Actually used provider name (may differ if fallback)
|
||||
public $fallback_used; // True if fallback occurred
|
||||
public $warnings; // Array of warning messages
|
||||
|
||||
public function __construct( $provider, $selected, $actual, $fallback, $warnings = array() ) {
|
||||
$this->provider = $provider;
|
||||
$this->selected_provider = $selected;
|
||||
$this->actual_provider = $actual;
|
||||
$this->fallback_used = $fallback;
|
||||
$this->warnings = $warnings;
|
||||
}
|
||||
}
|
||||
|
||||
class WP_Agentic_Writer_Provider_Manager {
|
||||
/**
|
||||
* Get provider instance for specific task type
|
||||
*
|
||||
* @param string $type Task type (chat, clarity, planning, writing, refinement, image).
|
||||
* @return WP_Agentic_Writer_AI_Provider_Interface Provider instance.
|
||||
* @return WPAW_Provider_Selection_Result Provider selection result with metadata.
|
||||
*/
|
||||
public static function get_provider_for_task( $type ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$task_providers = $settings['task_providers'] ?? array();
|
||||
|
||||
|
||||
// Determine which provider to use for this task
|
||||
$provider_name = $task_providers[ $type ] ?? 'openrouter';
|
||||
|
||||
$requested_provider = $task_providers[ $type ] ?? 'openrouter';
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( "WPAW Provider Manager: task={$type}, provider_name={$requested_provider}, task_providers=" . json_encode( $task_providers ) );
|
||||
}
|
||||
|
||||
$warnings = array();
|
||||
$fallback_used = false;
|
||||
$actual_provider = $requested_provider;
|
||||
|
||||
// Get provider instance with fallback logic
|
||||
$provider = self::get_provider_instance( $provider_name, $type );
|
||||
|
||||
$provider = self::get_provider_instance( $requested_provider, $type );
|
||||
|
||||
// If provider not configured or unavailable, fallback to OpenRouter
|
||||
if ( ! $provider || ! $provider->is_configured() ) {
|
||||
error_log( "Provider '{$provider_name}' not available for task '{$type}', using OpenRouter fallback" );
|
||||
return WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( "Provider '{$requested_provider}' not available for task '{$type}', using OpenRouter fallback" );
|
||||
}
|
||||
$warnings[] = "Provider '{$requested_provider}' unavailable, fell back to OpenRouter";
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$actual_provider = 'openrouter';
|
||||
$fallback_used = true;
|
||||
}
|
||||
|
||||
return $provider;
|
||||
|
||||
// For local backend, verify it's actually reachable before using it
|
||||
if ( 'local_backend' === $requested_provider && ! $fallback_used && method_exists( $provider, 'test_connection' ) ) {
|
||||
$test_result = $provider->test_connection();
|
||||
if ( is_wp_error( $test_result ) ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( "Local Backend not reachable for task '{$type}', using OpenRouter fallback. Error: " . $test_result->get_error_message() );
|
||||
}
|
||||
$warnings[] = "Local Backend not reachable, fell back to OpenRouter";
|
||||
$provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance();
|
||||
$actual_provider = 'openrouter';
|
||||
$fallback_used = true;
|
||||
}
|
||||
}
|
||||
|
||||
return new WPAW_Provider_Selection_Result(
|
||||
$provider,
|
||||
$requested_provider,
|
||||
$actual_provider,
|
||||
$fallback_used,
|
||||
$warnings
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -78,6 +78,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
wp_enqueue_style( 'wpaw-agentic-variables', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-variables.css', array(), WP_AGENTIC_WRITER_VERSION );
|
||||
wp_enqueue_style( 'wpaw-agentic-bootstrap-custom', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-bootstrap-custom.css', array( 'bootstrap', 'wpaw-agentic-variables' ), WP_AGENTIC_WRITER_VERSION );
|
||||
wp_enqueue_style( 'wpaw-agentic-components', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-components.css', array( 'wpaw-agentic-variables' ), WP_AGENTIC_WRITER_VERSION );
|
||||
wp_enqueue_style( 'wpaw-agentic-workflow', WP_AGENTIC_WRITER_URL . 'assets/css/agentic-workflow.css', array( 'wpaw-agentic-components' ), WP_AGENTIC_WRITER_VERSION );
|
||||
|
||||
// Legacy plugin styles
|
||||
$css_admin_path = WP_AGENTIC_WRITER_DIR . 'assets/css/admin-v2.css';
|
||||
@@ -101,14 +102,15 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
'nonce' => wp_create_nonce( 'wpaw_settings' ),
|
||||
'models' => $this->get_models_for_select(),
|
||||
'currentModels' => array(
|
||||
'planning' => $settings['planning_model'] ?? 'google/gemini-2.0-flash-exp:free',
|
||||
'writing' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet' ),
|
||||
'execution' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet' ),
|
||||
'clarity' => $settings['clarity_model'] ?? 'google/gemini-2.0-flash-exp:free',
|
||||
'refinement' => $settings['refinement_model'] ?? 'anthropic/claude-3.5-sonnet',
|
||||
'chat' => $settings['chat_model'] ?? 'google/gemini-2.0-flash-exp:free',
|
||||
'image' => $settings['image_model'] ?? 'openai/gpt-4o',
|
||||
'planning' => $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ),
|
||||
'writing' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) ),
|
||||
'execution' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) ),
|
||||
'clarity' => $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ),
|
||||
'refinement' => $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ),
|
||||
'chat' => $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ),
|
||||
'image' => $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ),
|
||||
),
|
||||
'presets' => $this->get_model_presets(),
|
||||
'i18n' => array(
|
||||
'refreshing' => __( 'Refreshing...', 'wp-agentic-writer' ),
|
||||
'refreshModels' => __( 'Refresh Models', 'wp-agentic-writer' ),
|
||||
@@ -122,6 +124,44 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get curated model presets (centralized source).
|
||||
*
|
||||
* These are intentional product decisions for different budget tiers.
|
||||
* Model IDs may differ from registry defaults to balance cost/quality.
|
||||
*
|
||||
* @since 0.2.0
|
||||
* @return array Curated model presets.
|
||||
*/
|
||||
public function get_model_presets() {
|
||||
return array(
|
||||
'budget' => array(
|
||||
'chat' => 'google/gemini-2.5-flash',
|
||||
'clarity' => 'google/gemini-2.5-flash',
|
||||
'planning' => 'google/gemini-2.5-flash',
|
||||
'writing' => 'mistralai/mistral-small-creative',
|
||||
'refinement' => 'google/gemini-2.5-flash',
|
||||
'image' => 'openai/gpt-4o',
|
||||
),
|
||||
'balanced' => array(
|
||||
'chat' => 'google/gemini-2.5-flash',
|
||||
'clarity' => 'google/gemini-2.5-flash',
|
||||
'planning' => 'google/gemini-2.5-flash',
|
||||
'writing' => 'anthropic/claude-3.5-sonnet',
|
||||
'refinement' => 'anthropic/claude-3.5-sonnet',
|
||||
'image' => 'openai/gpt-4o',
|
||||
),
|
||||
'premium' => array(
|
||||
'chat' => 'google/gemini-3-flash-preview',
|
||||
'clarity' => 'anthropic/claude-sonnet-4',
|
||||
'planning' => 'google/gemini-3-flash-preview',
|
||||
'writing' => 'openai/gpt-4.1',
|
||||
'refinement' => 'openai/gpt-4.1',
|
||||
'image' => 'openai/gpt-4o',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get models for select dropdowns.
|
||||
*
|
||||
@@ -188,26 +228,26 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
return array(
|
||||
'planning' => array(
|
||||
'recommended' => array(
|
||||
array( 'id' => 'google/gemini-2.0-flash-exp:free', 'name' => 'Google Gemini 2.0 Flash' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'planning' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'planning' ) ) ),
|
||||
),
|
||||
'all' => array(
|
||||
array( 'id' => 'google/gemini-2.0-flash-exp:free', 'name' => 'Google Gemini 2.0 Flash' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'planning' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'planning' ) ) ),
|
||||
),
|
||||
),
|
||||
'execution' => array(
|
||||
'recommended' => array(
|
||||
array( 'id' => 'anthropic/claude-3.5-sonnet', 'name' => 'Anthropic Claude 3.5 Sonnet' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_fallback_model( 'execution' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_fallback_model( 'execution' ) ) ),
|
||||
),
|
||||
'all' => array(
|
||||
array( 'id' => 'anthropic/claude-3.5-sonnet', 'name' => 'Anthropic Claude 3.5 Sonnet' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_fallback_model( 'execution' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_fallback_model( 'execution' ) ) ),
|
||||
),
|
||||
),
|
||||
'image' => array(
|
||||
'recommended' => array(
|
||||
array( 'id' => 'openai/gpt-4o', 'name' => 'OpenAI GPT-4o' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'image' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'image' ) ) ),
|
||||
),
|
||||
'all' => array(
|
||||
array( 'id' => 'openai/gpt-4o', 'name' => 'OpenAI GPT-4o' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'image' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'image' ) ) ),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -224,9 +264,9 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
// Handle flat model list from OpenRouter
|
||||
if ( ! empty( $models ) && array_keys( $models ) === range( 0, count( $models ) - 1 ) ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$planning_id = $settings['planning_model'] ?? 'google/gemini-2.0-flash-exp:free';
|
||||
$execution_id = $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$image_id = $settings['image_model'] ?? 'black-forest-labs/flux-schnell';
|
||||
$planning_id = $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' );
|
||||
$execution_id = $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'execution' );
|
||||
$image_id = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' );
|
||||
|
||||
$text_models = array();
|
||||
$image_models = array();
|
||||
@@ -266,10 +306,10 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
}
|
||||
}
|
||||
|
||||
$chat_id = $settings['chat_model'] ?? 'google/gemini-2.0-flash-exp:free';
|
||||
$clarity_id = $settings['clarity_model'] ?? 'google/gemini-2.0-flash-exp:free';
|
||||
$refinement_id = $settings['refinement_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$writing_id = $settings['writing_model'] ?? ( $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet' );
|
||||
$chat_id = $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' );
|
||||
$clarity_id = $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' );
|
||||
$refinement_id = $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' );
|
||||
$writing_id = $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) );
|
||||
|
||||
// Add currently selected models to text_models if not already present
|
||||
$current_model_ids = array( $planning_id, $execution_id, $chat_id, $clarity_id, $refinement_id, $writing_id );
|
||||
@@ -641,81 +681,80 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
}
|
||||
$where_clause = implode( ' AND ', $where );
|
||||
|
||||
// Get total count
|
||||
$total_items = $wpdb->get_var( "SELECT COUNT(*) FROM {$table_name} WHERE {$where_clause}" );
|
||||
// Get total count of distinct posts
|
||||
$total_items = $wpdb->get_var( "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE {$where_clause}" );
|
||||
$total_pages = ceil( $total_items / $per_page );
|
||||
|
||||
// Get all records grouped by post
|
||||
$all_records = $wpdb->get_results(
|
||||
"SELECT * FROM {$table_name} WHERE {$where_clause} ORDER BY post_id DESC, created_at DESC"
|
||||
// Optimized: Get grouped records with aggregation in SQL.
|
||||
// This pushes grouping and ordering to the database instead of PHP.
|
||||
$grouped_records_sql = $wpdb->get_results(
|
||||
$wpdb->prepare(
|
||||
"SELECT
|
||||
post_id,
|
||||
SUM(cost) as total_cost,
|
||||
COUNT(*) as call_count,
|
||||
MAX(created_at) as last_call
|
||||
FROM {$table_name}
|
||||
WHERE {$where_clause}
|
||||
GROUP BY post_id
|
||||
ORDER BY total_cost DESC
|
||||
LIMIT %d OFFSET %d",
|
||||
$per_page,
|
||||
$offset
|
||||
),
|
||||
ARRAY_A
|
||||
);
|
||||
|
||||
// Group records by post_id
|
||||
$grouped_records = array();
|
||||
foreach ( $all_records as $record ) {
|
||||
$post_id = $record->post_id;
|
||||
if ( ! isset( $grouped_records[ $post_id ] ) ) {
|
||||
$post_title = get_the_title( $post_id );
|
||||
if ( ! $post_title && $post_id > 0 ) {
|
||||
$post_title = sprintf( __( '[Removed Post #%d]', 'wp-agentic-writer' ), $post_id );
|
||||
$post_link = '';
|
||||
} elseif ( $post_id > 0 ) {
|
||||
$post_link = get_edit_post_link( $post_id, 'raw' );
|
||||
} else {
|
||||
$post_title = __( 'System/Other', 'wp-agentic-writer' );
|
||||
$post_link = '';
|
||||
}
|
||||
// Build grouped records with post details
|
||||
$formatted_records = array();
|
||||
$post_ids = array();
|
||||
|
||||
$grouped_records[ $post_id ] = array(
|
||||
'post_id' => $post_id,
|
||||
'post_title' => $post_title,
|
||||
'post_link' => $post_link,
|
||||
'total_cost' => 0,
|
||||
'call_count' => 0,
|
||||
'details' => array(),
|
||||
);
|
||||
foreach ( $grouped_records_sql as $row ) {
|
||||
$post_id = (int) $row['post_id'];
|
||||
$post_ids[] = $post_id;
|
||||
|
||||
if ( $post_id > 0 ) {
|
||||
$post_title = get_the_title( $post_id );
|
||||
if ( ! $post_title ) {
|
||||
$post_title = sprintf( __( '[Removed Post #%d]', 'wp-agentic-writer' ), $post_id );
|
||||
$post_link = '';
|
||||
} else {
|
||||
$post_link = get_edit_post_link( $post_id, 'raw' );
|
||||
}
|
||||
} else {
|
||||
$post_title = __( 'System/Other', 'wp-agentic-writer' );
|
||||
$post_link = '';
|
||||
}
|
||||
|
||||
$grouped_records[ $post_id ]['total_cost'] += (float) $record->cost;
|
||||
$grouped_records[ $post_id ]['call_count']++;
|
||||
$grouped_records[ $post_id ]['details'][] = array(
|
||||
'id' => $record->id,
|
||||
'created_at' => date_i18n( 'Y-m-d H:i:s', strtotime( $record->created_at ) ),
|
||||
'model' => $record->model,
|
||||
'action' => ucfirst( str_replace( '_', ' ', $record->action ) ),
|
||||
'input_tokens' => number_format( $record->input_tokens ),
|
||||
'output_tokens' => number_format( $record->output_tokens ),
|
||||
'cost' => number_format( $record->cost, 4 ),
|
||||
$formatted_records[] = array(
|
||||
'post_id' => $post_id,
|
||||
'post_title' => $post_title,
|
||||
'post_link' => $post_link,
|
||||
'total_cost' => number_format( (float) $row['total_cost'], 4 ),
|
||||
'call_count' => (int) $row['call_count'],
|
||||
'last_call' => date_i18n( 'Y-m-d H:i:s', strtotime( $row['last_call'] ) ),
|
||||
'details' => array(), // Lazy-loaded on expand
|
||||
);
|
||||
}
|
||||
|
||||
// Convert to indexed array and format
|
||||
$formatted_records = array();
|
||||
foreach ( $grouped_records as $group ) {
|
||||
$group['total_cost'] = number_format( $group['total_cost'], 4 );
|
||||
$formatted_records[] = $group;
|
||||
}
|
||||
// Get details for visible posts only (on-demand loading).
|
||||
// For better performance, we skip details here and let frontend request them.
|
||||
// The details can be loaded via a separate endpoint when user expands a row.
|
||||
|
||||
// Paginate grouped records
|
||||
$total_items = count( $formatted_records );
|
||||
$total_pages = ceil( $total_items / $per_page );
|
||||
$formatted_records = array_slice( $formatted_records, $offset, $per_page );
|
||||
|
||||
// Get summary stats
|
||||
$cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance();
|
||||
$total_all_time = $wpdb->get_var( "SELECT SUM(cost) FROM {$table_name}" );
|
||||
// Get summary stats (all-time aggregation in SQL)
|
||||
$total_all_time = $wpdb->get_var( "SELECT COALESCE(SUM(cost), 0) FROM {$table_name}" );
|
||||
$monthly_total = $cost_tracker->get_monthly_total();
|
||||
$today_total = $wpdb->get_var(
|
||||
$wpdb->prepare(
|
||||
"SELECT SUM(cost) FROM {$table_name} WHERE DATE(created_at) = %s",
|
||||
"SELECT COALESCE(SUM(cost), 0) FROM {$table_name} WHERE DATE(created_at) = %s",
|
||||
current_time( 'Y-m-d' )
|
||||
)
|
||||
);
|
||||
$total_posts = $wpdb->get_var( "SELECT COUNT(DISTINCT post_id) FROM {$table_name} WHERE post_id > 0" );
|
||||
$avg_per_post = $total_posts > 0 ? $total_all_time / $total_posts : 0;
|
||||
|
||||
// Get filter options
|
||||
$models = $wpdb->get_col( "SELECT DISTINCT model FROM {$table_name} ORDER BY model" );
|
||||
// Get filter options (distinct values from DB)
|
||||
$models = $wpdb->get_col( "SELECT DISTINCT model FROM {$table_name} ORDER BY model LIMIT 100" );
|
||||
$types = $wpdb->get_col( "SELECT DISTINCT action FROM {$table_name} ORDER BY action" );
|
||||
|
||||
wp_send_json_success( array(
|
||||
@@ -884,7 +923,7 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
|
||||
// Test API connection by making a simple request
|
||||
$response = wp_remote_get(
|
||||
'https://openrouter.ai/api/v1/models',
|
||||
'https://openrouter.ai/api/v1/models?output_modalities=all',
|
||||
array(
|
||||
'headers' => array(
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
@@ -985,13 +1024,13 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
$sanitized['brave_search_api_key'] = trim( $input['brave_search_api_key'] );
|
||||
}
|
||||
|
||||
// Sanitize model names (6 models)
|
||||
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? 'google/gemini-2.5-flash' );
|
||||
$sanitized['clarity_model'] = sanitize_text_field( $input['clarity_model'] ?? 'google/gemini-2.5-flash' );
|
||||
$sanitized['planning_model'] = sanitize_text_field( $input['planning_model'] ?? 'google/gemini-2.5-flash' );
|
||||
$sanitized['writing_model'] = sanitize_text_field( $input['writing_model'] ?? 'anthropic/claude-3.5-sonnet' );
|
||||
$sanitized['refinement_model'] = sanitize_text_field( $input['refinement_model'] ?? 'anthropic/claude-3.5-sonnet' );
|
||||
$sanitized['image_model'] = sanitize_text_field( $input['image_model'] ?? 'openai/gpt-4o' );
|
||||
// Sanitize model names (6 models) - using model registry for defaults
|
||||
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ) );
|
||||
$sanitized['clarity_model'] = sanitize_text_field( $input['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ) );
|
||||
$sanitized['planning_model'] = sanitize_text_field( $input['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ) );
|
||||
$sanitized['writing_model'] = sanitize_text_field( $input['writing_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) );
|
||||
$sanitized['refinement_model'] = sanitize_text_field( $input['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ) );
|
||||
$sanitized['image_model'] = sanitize_text_field( $input['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ) );
|
||||
|
||||
// Legacy support: map execution_model to writing_model
|
||||
if ( isset( $input['execution_model'] ) && ! isset( $input['writing_model'] ) ) {
|
||||
@@ -1103,15 +1142,15 @@ class WP_Agentic_Writer_Settings_V2 {
|
||||
* @return array View data.
|
||||
*/
|
||||
private function prepare_view_data( $settings ) {
|
||||
// Extract settings (6 models)
|
||||
// Extract settings (6 models) using model registry for defaults
|
||||
$api_key = $settings['openrouter_api_key'] ?? '';
|
||||
$brave_search_api_key = $settings['brave_search_api_key'] ?? '';
|
||||
$chat_model = $settings['chat_model'] ?? 'google/gemini-2.5-flash';
|
||||
$clarity_model = $settings['clarity_model'] ?? 'google/gemini-2.5-flash';
|
||||
$planning_model = $settings['planning_model'] ?? 'google/gemini-2.5-flash';
|
||||
$writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet' );
|
||||
$refinement_model = $settings['refinement_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
$chat_model = $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' );
|
||||
$clarity_model = $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' );
|
||||
$planning_model = $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' );
|
||||
$writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) );
|
||||
$refinement_model = $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' );
|
||||
$image_model = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' );
|
||||
$web_search_enabled = $settings['web_search_enabled'] ?? false;
|
||||
$search_engine = $settings['search_engine'] ?? 'auto';
|
||||
$search_depth = $settings['search_depth'] ?? 'medium';
|
||||
|
||||
@@ -69,13 +69,13 @@ class WP_Agentic_Writer_Settings {
|
||||
'nonce' => wp_create_nonce( 'wpaw_settings' ),
|
||||
'models' => $this->get_models_for_select(),
|
||||
'currentModels' => array(
|
||||
'planning' => $settings['planning_model'] ?? 'google/gemini-2.0-flash-exp:free',
|
||||
'writing' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet' ),
|
||||
'execution' => $settings['writing_model'] ?? ( $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet' ),
|
||||
'clarity' => $settings['clarity_model'] ?? 'google/gemini-2.0-flash-exp:free',
|
||||
'refinement' => $settings['refinement_model'] ?? 'anthropic/claude-3.5-sonnet',
|
||||
'chat' => $settings['chat_model'] ?? 'google/gemini-2.0-flash-exp:free',
|
||||
'image' => $settings['image_model'] ?? 'openai/gpt-4o',
|
||||
'planning' => $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ),
|
||||
'writing' => $settings['writing_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ),
|
||||
'execution' => $settings['writing_model'] ?? WPAW_Model_Registry::get_default_model( 'execution' ),
|
||||
'clarity' => $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ),
|
||||
'refinement' => $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ),
|
||||
'chat' => $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ),
|
||||
'image' => $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ),
|
||||
),
|
||||
) );
|
||||
}
|
||||
@@ -91,30 +91,30 @@ class WP_Agentic_Writer_Settings {
|
||||
$models = $provider->get_cached_models();
|
||||
|
||||
if ( is_wp_error( $models ) ) {
|
||||
// Return fallback defaults if API fails.
|
||||
// Return fallback defaults from model registry if API fails.
|
||||
return array(
|
||||
'planning' => array(
|
||||
'recommended' => array(
|
||||
array( 'id' => 'google/gemini-2.0-flash-exp:free', 'name' => 'Google Gemini 2.0 Flash' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'planning' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'planning' ) ) ),
|
||||
),
|
||||
'all' => array(
|
||||
array( 'id' => 'google/gemini-2.0-flash-exp:free', 'name' => 'Google Gemini 2.0 Flash' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'planning' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'planning' ) ) ),
|
||||
),
|
||||
),
|
||||
'execution' => array(
|
||||
'recommended' => array(
|
||||
array( 'id' => 'anthropic/claude-3.5-sonnet', 'name' => 'Anthropic Claude 3.5 Sonnet' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'execution' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'execution' ) ) ),
|
||||
),
|
||||
'all' => array(
|
||||
array( 'id' => 'anthropic/claude-3.5-sonnet', 'name' => 'Anthropic Claude 3.5 Sonnet' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'execution' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'execution' ) ) ),
|
||||
),
|
||||
),
|
||||
'image' => array(
|
||||
'recommended' => array(
|
||||
array( 'id' => 'openai/gpt-4o', 'name' => 'OpenAI GPT-4o' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'image' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'image' ) ) ),
|
||||
),
|
||||
'all' => array(
|
||||
array( 'id' => 'openai/gpt-4o', 'name' => 'OpenAI GPT-4o' ),
|
||||
array( 'id' => WPAW_Model_Registry::get_default_model( 'image' ), 'name' => WPAW_Model_Registry::get_model_display_name( WPAW_Model_Registry::get_default_model( 'image' ) ) ),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -135,9 +135,9 @@ class WP_Agentic_Writer_Settings {
|
||||
// Handle flat model list from OpenRouter.
|
||||
if ( ! empty( $models ) && array_keys( $models ) === range( 0, count( $models ) - 1 ) ) {
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$planning_id = $settings['planning_model'] ?? 'google/gemini-2.0-flash-exp:free';
|
||||
$execution_id = $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$image_id = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
$planning_id = $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' );
|
||||
$execution_id = $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'execution' );
|
||||
$image_id = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' );
|
||||
|
||||
$text_models = array();
|
||||
$image_models = array();
|
||||
@@ -194,7 +194,7 @@ class WP_Agentic_Writer_Settings {
|
||||
return null;
|
||||
};
|
||||
|
||||
$chat_id = $settings['chat_model'] ?? 'google/gemini-2.0-flash-exp:free';
|
||||
$chat_id = $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' );
|
||||
|
||||
return array(
|
||||
'planning' => array(
|
||||
@@ -323,12 +323,12 @@ class WP_Agentic_Writer_Settings {
|
||||
$sanitized['brave_search_api_key'] = trim( $input['brave_search_api_key'] ?? '' );
|
||||
|
||||
// Sanitize model names (6 models as per model-preset-brief.md).
|
||||
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? 'google/gemini-2.5-flash' );
|
||||
$sanitized['clarity_model'] = sanitize_text_field( $input['clarity_model'] ?? 'google/gemini-2.5-flash' );
|
||||
$sanitized['planning_model'] = sanitize_text_field( $input['planning_model'] ?? 'google/gemini-2.5-flash' );
|
||||
$sanitized['writing_model'] = sanitize_text_field( $input['writing_model'] ?? 'anthropic/claude-3.5-sonnet' );
|
||||
$sanitized['refinement_model'] = sanitize_text_field( $input['refinement_model'] ?? 'anthropic/claude-3.5-sonnet' );
|
||||
$sanitized['image_model'] = sanitize_text_field( $input['image_model'] ?? 'openai/gpt-4o' );
|
||||
$sanitized['chat_model'] = sanitize_text_field( $input['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' ) );
|
||||
$sanitized['clarity_model'] = sanitize_text_field( $input['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' ) );
|
||||
$sanitized['planning_model'] = sanitize_text_field( $input['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' ) );
|
||||
$sanitized['writing_model'] = sanitize_text_field( $input['writing_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) );
|
||||
$sanitized['refinement_model'] = sanitize_text_field( $input['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' ) );
|
||||
$sanitized['image_model'] = sanitize_text_field( $input['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' ) );
|
||||
// Legacy support: map execution_model to writing_model
|
||||
if ( isset( $input['execution_model'] ) && ! isset( $input['writing_model'] ) ) {
|
||||
$sanitized['writing_model'] = sanitize_text_field( $input['execution_model'] );
|
||||
@@ -394,12 +394,12 @@ class WP_Agentic_Writer_Settings {
|
||||
// Extract settings (6 models as per model-preset-brief.md).
|
||||
$api_key = $settings['openrouter_api_key'] ?? '';
|
||||
$brave_api_key = $settings['brave_search_api_key'] ?? '';
|
||||
$chat_model = $settings['chat_model'] ?? 'google/gemini-2.5-flash';
|
||||
$clarity_model = $settings['clarity_model'] ?? 'google/gemini-2.5-flash';
|
||||
$planning_model = $settings['planning_model'] ?? 'google/gemini-2.5-flash';
|
||||
$writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? 'anthropic/claude-3.5-sonnet' );
|
||||
$refinement_model = $settings['refinement_model'] ?? 'anthropic/claude-3.5-sonnet';
|
||||
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
|
||||
$chat_model = $settings['chat_model'] ?? WPAW_Model_Registry::get_default_model( 'chat' );
|
||||
$clarity_model = $settings['clarity_model'] ?? WPAW_Model_Registry::get_default_model( 'clarity' );
|
||||
$planning_model = $settings['planning_model'] ?? WPAW_Model_Registry::get_default_model( 'planning' );
|
||||
$writing_model = $settings['writing_model'] ?? ( $settings['execution_model'] ?? WPAW_Model_Registry::get_default_model( 'writing' ) );
|
||||
$refinement_model = $settings['refinement_model'] ?? WPAW_Model_Registry::get_default_model( 'refinement' );
|
||||
$image_model = $settings['image_model'] ?? WPAW_Model_Registry::get_default_model( 'image' );
|
||||
$web_search_enabled = $settings['web_search_enabled'] ?? false;
|
||||
$search_engine = $settings['search_engine'] ?? 'auto';
|
||||
$search_depth = $settings['search_depth'] ?? 'medium';
|
||||
@@ -1023,7 +1023,9 @@ class WP_Agentic_Writer_Settings {
|
||||
|
||||
<script>
|
||||
function wpawApplyPreset(preset) {
|
||||
// Preset configurations with valid OpenRouter model IDs
|
||||
// Curated presets for legacy settings UI. These should be manually kept
|
||||
// in sync with WP_Agentic_Writer_Settings_V2::get_model_presets().
|
||||
// Model IDs balance cost/quality per tier.
|
||||
const presets = {
|
||||
budget: {
|
||||
chat: 'google/gemini-2.5-flash',
|
||||
@@ -1031,7 +1033,7 @@ class WP_Agentic_Writer_Settings {
|
||||
planning: 'google/gemini-2.5-flash',
|
||||
writing: 'mistralai/mistral-small-creative',
|
||||
refinement: 'google/gemini-2.5-flash',
|
||||
image: 'black-forest-labs/flux.2-klein'
|
||||
image: 'openai/gpt-4o'
|
||||
},
|
||||
balanced: {
|
||||
chat: 'google/gemini-2.5-flash',
|
||||
@@ -1039,7 +1041,7 @@ class WP_Agentic_Writer_Settings {
|
||||
planning: 'google/gemini-2.5-flash',
|
||||
writing: 'anthropic/claude-3.5-sonnet',
|
||||
refinement: 'anthropic/claude-3.5-sonnet',
|
||||
image: 'sourceful/riverflow-v2-max'
|
||||
image: 'openai/gpt-4o'
|
||||
},
|
||||
premium: {
|
||||
chat: 'google/gemini-3-flash-preview',
|
||||
@@ -1047,7 +1049,7 @@ class WP_Agentic_Writer_Settings {
|
||||
planning: 'google/gemini-3-flash-preview',
|
||||
writing: 'openai/gpt-4.1',
|
||||
refinement: 'openai/gpt-4.1',
|
||||
image: 'black-forest-labs/flux.2-max'
|
||||
image: 'openai/gpt-4o'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
588
includes/class-wp-ai-client-wrapper.php
Normal file
588
includes/class-wp-ai-client-wrapper.php
Normal file
@@ -0,0 +1,588 @@
|
||||
<?php
|
||||
/**
|
||||
* WordPress AI Client Integration
|
||||
*
|
||||
* Provides backward-compatible AI functionality that leverages WordPress 7.0's
|
||||
* native AI Client SDK when available, with fallback to legacy implementation.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WordPress AI Client SDK is available
|
||||
*
|
||||
* @return bool True if wp_ai_client_prompt() function exists.
|
||||
*/
|
||||
function wpaw_is_wp_ai_client_available() {
|
||||
return function_exists( 'wp_ai_client_prompt' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WordPress AI Client supports text generation
|
||||
*
|
||||
* @return bool True if text generation is supported.
|
||||
*/
|
||||
function wpaw_wp_ai_supports_text() {
|
||||
if ( ! wpaw_is_wp_ai_client_available() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$builder = wp_ai_client_prompt( 'test' );
|
||||
return $builder->is_supported_for_text_generation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WordPress AI Client supports image generation
|
||||
*
|
||||
* @return bool True if image generation is supported.
|
||||
*/
|
||||
function wpaw_wp_ai_supports_images() {
|
||||
if ( ! wpaw_is_wp_ai_client_available() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$builder = wp_ai_client_prompt( 'test' );
|
||||
return $builder->is_supported_for_image_generation();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WordPress AI Client supports speech generation
|
||||
*
|
||||
* @return bool True if speech generation is supported.
|
||||
*/
|
||||
function wpaw_wp_ai_supports_speech() {
|
||||
if ( ! wpaw_is_wp_ai_client_available() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$builder = wp_ai_client_prompt( 'test' );
|
||||
return $builder->is_supported_for_speech_generation();
|
||||
}
|
||||
|
||||
/**
|
||||
* WPAW_WP_AI_Client class
|
||||
*
|
||||
* Provides unified AI interface with WordPress 7.0 integration.
|
||||
* Falls back to legacy providers when core AI is unavailable.
|
||||
*/
|
||||
class WPAW_WP_AI_Client {
|
||||
|
||||
/**
|
||||
* Singleton instance
|
||||
*
|
||||
* @var WPAW_WP_AI_Client
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* Whether WordPress AI Client is available
|
||||
*
|
||||
* @var bool
|
||||
*/
|
||||
private $core_available;
|
||||
|
||||
/**
|
||||
* Model preferences for different tasks
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $model_preferences = array(
|
||||
'chat' => array( 'claude-sonnet-4-20250514', 'gpt-4o', 'gemini-2.5-flash' ),
|
||||
'clarity' => array( 'claude-haiku-4-20250514', 'gpt-4o-mini', 'gemini-2.0-flash' ),
|
||||
'planning' => array( 'claude-sonnet-4-20250514', 'gpt-4o', 'gemini-2.5-flash' ),
|
||||
'writing' => array( 'claude-sonnet-4-20250514', 'gpt-4o', 'gemini-2.5-flash' ),
|
||||
'refinement' => array( 'claude-haiku-4-20250514', 'gpt-4o-mini', 'gemini-2.0-flash' ),
|
||||
'seo' => array( 'claude-sonnet-4-20250514', 'gpt-4o', 'gemini-2.5-flash' ),
|
||||
'title' => array( 'claude-haiku-4-20250514', 'gpt-4o-mini', 'gemini-2.0-flash' ),
|
||||
);
|
||||
|
||||
/**
|
||||
* Temperature settings for different tasks
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $temperature_settings = array(
|
||||
'chat' => 0.7,
|
||||
'clarity' => 0.3,
|
||||
'planning' => 0.6,
|
||||
'writing' => 0.7,
|
||||
'refinement' => 0.5,
|
||||
'seo' => 0.5,
|
||||
'title' => 0.5,
|
||||
);
|
||||
|
||||
/**
|
||||
* Get singleton instance
|
||||
*
|
||||
* @return WPAW_WP_AI_Client
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( null === self::$instance ) {
|
||||
self::$instance = new self();
|
||||
}
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructor
|
||||
*/
|
||||
private function __construct() {
|
||||
$this->core_available = wpaw_is_wp_ai_client_available();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if using WordPress AI Client (true) or legacy (false)
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function using_core() {
|
||||
return $this->core_available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available AI mode
|
||||
*
|
||||
* @return string 'core', 'openrouter', or 'local'
|
||||
*/
|
||||
public function get_ai_mode() {
|
||||
if ( $this->core_available && wpaw_wp_ai_supports_text() ) {
|
||||
return 'core';
|
||||
}
|
||||
|
||||
$settings = get_option( 'wp_agentic_writer_settings', array() );
|
||||
$provider = $settings['default_provider'] ?? 'openrouter';
|
||||
|
||||
if ( $provider === 'local_backend' && class_exists( 'WP_Agentic_Writer_Local_Backend_Provider' ) ) {
|
||||
$local = new WP_Agentic_Writer_Local_Backend_Provider();
|
||||
if ( $local->is_configured() ) {
|
||||
return 'local';
|
||||
}
|
||||
}
|
||||
|
||||
return 'openrouter';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text using WordPress AI Client or fallback
|
||||
*
|
||||
* @param string $prompt The prompt text.
|
||||
* @param array $options Additional options (task_type, temperature, max_tokens).
|
||||
* @return string|WP_Error Generated text or error.
|
||||
*/
|
||||
public function generate_text( $prompt, $options = array() ) {
|
||||
$task_type = $options['task_type'] ?? 'chat';
|
||||
$temperature = $options['temperature'] ?? ( $this->temperature_settings[ $task_type ] ?? 0.7 );
|
||||
$max_tokens = $options['max_tokens'] ?? 4096;
|
||||
|
||||
// Try WordPress AI Client first
|
||||
if ( $this->core_available && wpaw_wp_ai_supports_text() ) {
|
||||
$models = $this->model_preferences[ $task_type ] ?? $this->model_preferences['chat'];
|
||||
|
||||
$builder = wp_ai_client_prompt()
|
||||
->with_text( $prompt )
|
||||
->using_temperature( $temperature )
|
||||
->using_max_tokens( $max_tokens )
|
||||
->using_model_preference( ...$models );
|
||||
|
||||
$result = $builder->generate_text();
|
||||
|
||||
if ( ! is_wp_error( $result ) ) {
|
||||
// Track usage if cost tracker available
|
||||
if ( class_exists( 'WP_Agentic_Writer_Cost_Tracker' ) ) {
|
||||
$cost = $this->estimate_cost( $result->get_usage() ?? array(), $models[0] );
|
||||
WP_Agentic_Writer_Cost_Tracker::get_instance()->record_usage_full(
|
||||
$options['post_id'] ?? 0,
|
||||
$models[0], // actual model used
|
||||
$task_type,
|
||||
$result->get_usage()['input_tokens'] ?? 0,
|
||||
$result->get_usage()['output_tokens'] ?? 0,
|
||||
$cost,
|
||||
'core', // WP AI Client provider
|
||||
$options['session_id'] ?? '',
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
return $result->get_text();
|
||||
}
|
||||
|
||||
error_log( 'WP Agentic Writer: Core AI failed, falling back to legacy. Error: ' . $result->get_error_message() );
|
||||
}
|
||||
|
||||
// Fallback to legacy implementation
|
||||
return $this->generate_text_legacy( $prompt, $options );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text using legacy provider
|
||||
*
|
||||
* @param string $prompt The prompt text.
|
||||
* @param array $options Additional options.
|
||||
* @return string|WP_Error Generated text or error.
|
||||
*/
|
||||
public function generate_text_legacy( $prompt, $options = array() ) {
|
||||
$task_type = $options['task_type'] ?? 'chat';
|
||||
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $task_type );
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
$messages = array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
),
|
||||
);
|
||||
|
||||
$params = array(
|
||||
'temperature' => $options['temperature'] ?? ( $this->temperature_settings[ $task_type ] ?? 0.7 ),
|
||||
'max_tokens' => $options['max_tokens'] ?? 4096,
|
||||
);
|
||||
|
||||
$response = $provider->chat( $messages, $params, $task_type );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Track usage
|
||||
if ( class_exists( 'WP_Agentic_Writer_Cost_Tracker' ) ) {
|
||||
$cost = $response['cost'] ?? 0;
|
||||
WP_Agentic_Writer_Cost_Tracker::get_instance()->record_usage_full(
|
||||
$options['post_id'] ?? 0,
|
||||
$provider_result->selected_provider . '/' . ($response['model'] ?? 'unknown'),
|
||||
$task_type,
|
||||
$response['input_tokens'] ?? 0,
|
||||
$response['output_tokens'] ?? 0,
|
||||
$cost,
|
||||
$provider_result->actual_provider,
|
||||
$options['session_id'] ?? '',
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
return $response['content'] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate text with streaming callback
|
||||
*
|
||||
* @param string $prompt The prompt text.
|
||||
* @param callable $callback Callback function for each chunk.
|
||||
* @param array $options Additional options.
|
||||
* @return bool True on success.
|
||||
*/
|
||||
public function generate_text_streaming( $prompt, $callback, $options = array() ) {
|
||||
$task_type = $options['task_type'] ?? 'chat';
|
||||
|
||||
// Note: WordPress AI Client doesn't support streaming yet
|
||||
// Use legacy provider for streaming
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $task_type );
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
if ( method_exists( $provider, 'chat_stream' ) ) {
|
||||
$params = array(
|
||||
'temperature' => $options['temperature'] ?? ( $this->temperature_settings[ $task_type ] ?? 0.7 ),
|
||||
'max_tokens' => $options['max_tokens'] ?? 8192,
|
||||
);
|
||||
|
||||
$result = $provider->chat_stream(
|
||||
array(
|
||||
array(
|
||||
'role' => 'user',
|
||||
'content' => $prompt,
|
||||
),
|
||||
),
|
||||
$params,
|
||||
$task_type,
|
||||
$callback
|
||||
);
|
||||
|
||||
return ! is_wp_error( $result );
|
||||
}
|
||||
|
||||
// Fallback to non-streaming
|
||||
$result = $this->generate_text_legacy( $prompt, $options );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Call back with full result
|
||||
call_user_func( $callback, $result );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate image using WordPress AI Client or fallback
|
||||
*
|
||||
* @param string $prompt The image prompt.
|
||||
* @param array $options Additional options (size, style).
|
||||
* @return array|WP_Error Image data or error.
|
||||
*/
|
||||
public function generate_image( $prompt, $options = array() ) {
|
||||
$size = $options['size'] ?? '1024x1024';
|
||||
$style = $options['style'] ?? 'natural';
|
||||
|
||||
// Try WordPress AI Client first
|
||||
if ( $this->core_available && wpaw_wp_ai_supports_images() ) {
|
||||
$builder = wp_ai_client_prompt()
|
||||
->with_text( $prompt )
|
||||
->as_output_modality_image();
|
||||
|
||||
$result = $builder->generate_image();
|
||||
|
||||
if ( ! is_wp_error( $result ) ) {
|
||||
return array(
|
||||
'url' => $result->get_url(),
|
||||
'data_uri' => $result->get_data_uri(),
|
||||
'revised_prompt' => method_exists( $result, 'get_revised_prompt' ) ? $result->get_revised_prompt() : $prompt,
|
||||
);
|
||||
}
|
||||
|
||||
error_log( 'WP Agentic Writer: Core image generation failed: ' . $result->get_error_message() );
|
||||
}
|
||||
|
||||
// Fallback to legacy image manager
|
||||
if ( class_exists( 'WP_Agentic_Writer_Image_Manager' ) ) {
|
||||
$manager = WP_Agentic_Writer_Image_Manager::get_instance();
|
||||
return $manager->generate_image( $prompt, $options );
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'image_generation_unavailable',
|
||||
__( 'Image generation is not available. Configure AI provider in WordPress Settings.', 'wp-agentic-writer' ),
|
||||
array( 'status' => 400 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate structured JSON response
|
||||
*
|
||||
* @param string $prompt The prompt.
|
||||
* @param array $schema JSON schema for response.
|
||||
* @param array $options Additional options.
|
||||
* @return array|WP_Error Parsed JSON or error.
|
||||
*/
|
||||
public function generate_json( $prompt, $schema, $options = array() ) {
|
||||
$task_type = $options['task_type'] ?? 'chat';
|
||||
|
||||
// Try WordPress AI Client first
|
||||
if ( $this->core_available && wpaw_wp_ai_supports_text() ) {
|
||||
$models = $this->model_preferences[ $task_type ] ?? $this->model_preferences['chat'];
|
||||
|
||||
$builder = wp_ai_client_prompt()
|
||||
->with_text( $prompt )
|
||||
->using_temperature( $options['temperature'] ?? 0.3 )
|
||||
->as_json_response( $schema )
|
||||
->using_model_preference( ...$models );
|
||||
|
||||
$result = $builder->generate_text();
|
||||
|
||||
if ( ! is_wp_error( $result ) ) {
|
||||
$json = json_decode( $result->get_text(), true );
|
||||
if ( json_last_error() === JSON_ERROR_NONE ) {
|
||||
return $json;
|
||||
}
|
||||
error_log( 'WP Agentic Writer: JSON parse error: ' . json_last_error_msg() );
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy with manual JSON extraction
|
||||
$text = $this->generate_text_legacy( $prompt . "\n\nRespond with ONLY valid JSON, no additional text.", $options );
|
||||
|
||||
if ( is_wp_error( $text ) ) {
|
||||
return $text;
|
||||
}
|
||||
|
||||
// Try to extract JSON from response
|
||||
$text = trim( $text );
|
||||
|
||||
// Remove code block markers if present
|
||||
$text = preg_replace( '/^```(?:json)?\s*/i', '', $text );
|
||||
$text = preg_replace( '/\s*```$/i', '', $text );
|
||||
|
||||
$result = json_decode( $text, true );
|
||||
|
||||
if ( json_last_error() === JSON_ERROR_NONE ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
return new WP_Error(
|
||||
'json_parse_error',
|
||||
__( 'Failed to parse JSON response', 'wp-agentic-writer' ),
|
||||
array( 'status' => 500 )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect user intent from message
|
||||
*
|
||||
* @param string $message User message.
|
||||
* @param bool $has_plan Whether user has an existing plan.
|
||||
* @param string $mode Current agent mode.
|
||||
* @return array Intent result with type and cost.
|
||||
*/
|
||||
public function detect_intent( $message, $has_plan = false, $mode = 'chat' ) {
|
||||
$options = array(
|
||||
'task_type' => 'clarity',
|
||||
'max_tokens' => 50,
|
||||
'temperature' => 0.1,
|
||||
);
|
||||
|
||||
$prompt = "Based on the user's message, determine their intent. Choose ONE:
|
||||
|
||||
1. \"create_outline\" - User wants to create an article outline/structure
|
||||
2. \"start_writing\" - User wants to write the full article
|
||||
3. \"refine_content\" - User wants to improve existing content
|
||||
4. \"add_section\" - User wants to add a new section
|
||||
5. \"continue_chat\" - User wants to continue discussing/exploring
|
||||
6. \"clarify\" - User is asking questions or needs clarification
|
||||
|
||||
Consider:
|
||||
- The user's explicit request
|
||||
- Whether they have an outline already (has_plan: " . ( $has_plan ? 'true' : 'false' ) . ")
|
||||
- Current mode (current_mode: {$mode})
|
||||
|
||||
User's message: \"{$message}\"
|
||||
|
||||
Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation.";
|
||||
|
||||
$result = $this->generate_text( $prompt, $options );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return array(
|
||||
'intent' => 'continue_chat',
|
||||
'cost' => 0,
|
||||
'error' => $result->get_error_message(),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate intent
|
||||
$intent = trim( strtolower( $result ) );
|
||||
$intent = preg_replace( '/["\'\\s]/', '', $intent );
|
||||
|
||||
$valid_intents = array( 'create_outline', 'start_writing', 'refine_content', 'add_section', 'continue_chat', 'clarify' );
|
||||
|
||||
if ( ! in_array( $intent, $valid_intents, true ) ) {
|
||||
$intent = 'continue_chat';
|
||||
}
|
||||
|
||||
return array(
|
||||
'intent' => $intent,
|
||||
'cost' => 0.001, // Estimated cost
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate title for content
|
||||
*
|
||||
* @param string $content Content to generate title for.
|
||||
* @param array $options Additional options.
|
||||
* @return string|WP_Error Generated title or error.
|
||||
*/
|
||||
public function generate_title( $content, $options = array() ) {
|
||||
$options['task_type'] = 'title';
|
||||
$options['max_tokens'] = 60;
|
||||
|
||||
$prompt = "Generate a catchy, SEO-friendly title (max 60 characters) for the following content. Only return the title, no additional text:\n\n" . substr( $content, 0, 1000 );
|
||||
|
||||
return $this->generate_text( $prompt, $options );
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate excerpt for content
|
||||
*
|
||||
* @param string $content Content to generate excerpt for.
|
||||
* @param array $options Additional options.
|
||||
* @return string|WP_Error Generated excerpt or error.
|
||||
*/
|
||||
public function generate_excerpt( $content, $options = array() ) {
|
||||
$options['task_type'] = 'title';
|
||||
$options['max_tokens'] = 160;
|
||||
|
||||
$prompt = "Generate a compelling excerpt (max 160 characters) for the following content. Only return the excerpt, no additional text:\n\n" . substr( $content, 0, 2000 );
|
||||
|
||||
return $this->generate_text( $prompt, $options );
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize context for token optimization
|
||||
*
|
||||
* @param array $messages Chat messages to summarize.
|
||||
* @param int $max_tokens Maximum tokens for summary.
|
||||
* @return string|WP_Error Summary or error.
|
||||
*/
|
||||
public function summarize_context( $messages, $max_tokens = 1000 ) {
|
||||
$options = array(
|
||||
'task_type' => 'clarity',
|
||||
'max_tokens' => $max_tokens,
|
||||
'temperature' => 0.3,
|
||||
);
|
||||
|
||||
// Build context string
|
||||
$context = '';
|
||||
foreach ( $messages as $msg ) {
|
||||
$role = $msg['role'] ?? 'user';
|
||||
$content = $msg['content'] ?? '';
|
||||
if ( is_array( $content ) ) {
|
||||
$content = $content[0]['text'] ?? '';
|
||||
}
|
||||
$context .= "[{$role}]: " . substr( $content, 0, 500 ) . "\n\n";
|
||||
}
|
||||
|
||||
$prompt = "Summarize the following conversation, preserving key information and context. Focus on:\n- Topic and goal\n- Key decisions or plans made\n- Important details or constraints\n\nConversation:\n{$context}\n\nProvide a concise summary:";
|
||||
|
||||
return $this->generate_text( $prompt, $options );
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate cost based on usage
|
||||
*
|
||||
* @param array $usage Token usage data.
|
||||
* @param string $model Model name.
|
||||
* @return float Estimated cost in USD.
|
||||
*/
|
||||
private function estimate_cost( $usage, $model ) {
|
||||
// Simple cost estimation
|
||||
$input_tokens = $usage['input_tokens'] ?? 0;
|
||||
$output_tokens = $usage['output_tokens'] ?? 0;
|
||||
|
||||
// Rough estimates per 1M tokens (in USD)
|
||||
$rates = array(
|
||||
'claude-sonnet' => 3.00,
|
||||
'claude-haiku' => 0.25,
|
||||
'gpt-4o' => 5.00,
|
||||
'gpt-4o-mini' => 0.15,
|
||||
'gemini' => 0.50,
|
||||
);
|
||||
|
||||
$rate = 1.00; // Default rate
|
||||
foreach ( $rates as $key => $value ) {
|
||||
if ( stripos( $model, $key ) !== false ) {
|
||||
$rate = $value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ( ( $input_tokens + $output_tokens ) / 1000000 ) * $rate;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get capability status
|
||||
*
|
||||
* @return array Capabilities status.
|
||||
*/
|
||||
public function get_capabilities() {
|
||||
return array(
|
||||
'core_available' => $this->core_available,
|
||||
'text_support' => wpaw_wp_ai_supports_text(),
|
||||
'image_support' => wpaw_wp_ai_supports_images(),
|
||||
'speech_support' => wpaw_wp_ai_supports_speech(),
|
||||
'current_mode' => $this->get_ai_mode(),
|
||||
'streaming_available' => false, // Core doesn't support streaming yet
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user