Files
wp-agentic-writer/includes/class-image-manager.php
Dwindi Ramadhana d2c10756ab Add AI writing assistant plugin with local backend, brave search, and image generation support
- Implement local backend AI provider with Ollama integration
- Add Brave Search API integration for real-time search suggestions
- Add image generation manager with multiple AI providers
- Create hybrid provider system with local/cloud fallback
- Add comprehensive settings UI with provider management
- Implement Gutenberg sidebar with writing assistance controls
- Add SEO schema generation for AI-generated content
- Multiple provider support: OpenRouter, local backend, Codex
2026-05-17 10:48:05 +07:00

690 lines
20 KiB
PHP

<?php
/**
* Image Manager Class
*
* Handles image generation, variant management, and WordPress Media integration.
*
* @package WP_Agentic_Writer
* @since 0.1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Image Manager class.
*/
class WP_Agentic_Writer_Image_Manager {
/**
* Singleton instance.
*
* @var WP_Agentic_Writer_Image_Manager
*/
private static $instance = null;
/**
* Get singleton instance.
*
* @return WP_Agentic_Writer_Image_Manager
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
// Private constructor for singleton.
}
/**
* Create database tables on plugin activation.
*/
public function create_tables() {
global $wpdb;
$charset_collate = $wpdb->get_charset_collate();
// Table 1: wp_wpaw_images
$table_images = $wpdb->prefix . 'wpaw_images';
$sql_images = "CREATE TABLE IF NOT EXISTS `{$table_images}` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) NOT NULL,
`agent_image_id` varchar(50) NOT NULL,
`placement` varchar(100) DEFAULT NULL,
`section_title` varchar(255) DEFAULT NULL,
`prompt_initial` text NOT NULL,
`alt_text_initial` text DEFAULT NULL,
`prompt_edited` text DEFAULT NULL,
`alt_text_edited` text DEFAULT NULL,
`attachment_id` bigint(20) DEFAULT NULL,
`status` varchar(30) DEFAULT 'pending',
`cost_estimate` decimal(10, 4) DEFAULT NULL,
`cost_actual` decimal(10, 4) DEFAULT NULL,
`image_model` varchar(100) DEFAULT NULL,
`created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_post` (`post_id`),
KEY `idx_agent_image_id` (`post_id`, `agent_image_id`),
KEY `idx_status` (`status`),
KEY `idx_created` (`created_at`)
) {$charset_collate};";
// Table 2: wp_wpaw_images_variants
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
$sql_variants = "CREATE TABLE IF NOT EXISTS `{$table_variants}` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`agentic_image_id` bigint(20) NOT NULL,
`post_id` bigint(20) NOT NULL,
`agent_image_id` varchar(50) NOT NULL,
`variant_number` int(11) DEFAULT 1,
`temp_file_path` varchar(500) NOT NULL,
`temp_file_url` varchar(500) NOT NULL,
`file_size` int(11) DEFAULT NULL,
`prompt_used` text DEFAULT NULL,
`image_model_used` varchar(100) DEFAULT NULL,
`generation_time` int(11) DEFAULT NULL,
`cost` decimal(10, 4) DEFAULT NULL,
`is_selected` tinyint(1) DEFAULT 0,
`selected_at` datetime DEFAULT NULL,
`status` varchar(30) DEFAULT 'temp',
`created_at` datetime DEFAULT CURRENT_TIMESTAMP NOT NULL,
`deleted_at` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_agentic_image` (`agentic_image_id`),
KEY `idx_post` (`post_id`),
KEY `idx_status` (`status`),
KEY `idx_created` (`created_at`)
) {$charset_collate};";
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
dbDelta( $sql_images );
dbDelta( $sql_variants );
// Create temp directory.
$this->create_temp_directory();
}
/**
* Create temp directory for image storage.
*/
private function create_temp_directory() {
$upload_dir = wp_upload_dir();
$temp_dir = $upload_dir['basedir'] . '/wpaw';
if ( ! file_exists( $temp_dir ) ) {
wp_mkdir_p( $temp_dir );
// Add .htaccess to prevent direct access.
$htaccess = $temp_dir . '/.htaccess';
if ( ! file_exists( $htaccess ) ) {
file_put_contents( $htaccess, "Options -Indexes\n" );
}
// Add index.php for security.
$index = $temp_dir . '/index.php';
if ( ! file_exists( $index ) ) {
file_put_contents( $index, "<?php // Silence is golden\n" );
}
}
}
/**
* Analyze article for optimal image placement.
*
* @param string $article_markdown Article content in markdown.
* @param int $post_id Post ID.
* @return array|WP_Error Placement data or error.
*/
public function analyze_article_for_images( $article_markdown, $post_id ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
$writing_model = $settings['writing_model'] ?? 'anthropic/claude-3.5-sonnet';
$system_prompt = "You are an expert content strategist analyzing articles for optimal image placement.
Your task: Identify 2-3 strategic locations where images would enhance understanding and engagement.
RULES:
1. Prioritize placement after introduction (hero image)
2. Consider complex sections that need visual aids
3. Look for opportunities before conclusions
4. Maximum 3 images per article
Return JSON:
{
\"recommended_image_count\": 3,
\"image_placement_points\": [
{
\"agent_image_id\": \"img_hero_1\",
\"placement\": \"after_introduction\",
\"section_title\": \"Introduction\",
\"image_type\": \"hero_dashboard\",
\"reasoning\": \"Sets visual tone for article\"
}
]
}";
$messages = array(
array(
'role' => 'user',
'content' => "Analyze this article for image placement:\n\n" . $article_markdown,
),
);
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
$response = $provider->chat( $messages, array( 'temperature' => 0.3 ), 'planning' );
if ( is_wp_error( $response ) ) {
return $response;
}
// Extract JSON from response.
$json_match = array();
if ( preg_match( '/\{[\s\S]*\}/m', $response['content'], $json_match ) ) {
$placement_data = json_decode( $json_match[0], true );
if ( JSON_ERROR_NONE === json_last_error() ) {
return $placement_data;
}
}
return new WP_Error( 'parse_error', 'Failed to parse placement analysis' );
}
/**
* Generate image prompts optimized for specific image model.
*
* @param string $article_markdown Article content.
* @param array $placement_data Placement analysis.
* @param int $post_id Post ID.
* @return array|WP_Error Image specifications or error.
*/
public function generate_image_prompts( $article_markdown, $placement_data, $post_id ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
$writing_model = $settings['writing_model'] ?? 'anthropic/claude-3.5-sonnet';
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
// Get model-specific prompt guidance.
$prompt_guidance = $this->get_prompt_guidance_for_model( $image_model );
$system_prompt = "You are an Image Prompt Engineer specializing in {$prompt_guidance['model_name']}.
TARGET MODEL: {$prompt_guidance['model_name']}
PROMPT LENGTH: {$prompt_guidance['prompt_length']}
COMPLEXITY: {$prompt_guidance['complexity']}
{$prompt_guidance['guidance']}
TEMPLATE: {$prompt_guidance['template']}
Generate precise, cost-efficient prompts that exploit this model's strengths.
Return JSON:
{
\"images\": [
{
\"agent_image_id\": \"img_hero_1\",
\"placement\": \"after_introduction\",
\"section_title\": \"Introduction\",
\"prompt\": \"[Model-optimized prompt]\",
\"alt\": \"Descriptive alt text\",
\"image_model\": \"{$image_model}\"
}
]
}";
$user_input = wp_json_encode(
array(
'article' => $article_markdown,
'placement_points' => $placement_data['image_placement_points'],
'image_count' => $placement_data['recommended_image_count'],
'target_image_model' => $image_model,
)
);
$messages = array(
array(
'role' => 'user',
'content' => "Generate image prompts:\n\n" . $user_input,
),
);
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' );
$response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' );
if ( is_wp_error( $response ) ) {
return $response;
}
// Extract JSON.
$json_match = array();
if ( preg_match( '/\{[\s\S]*\}/m', $response['content'], $json_match ) ) {
$image_specs = json_decode( $json_match[0], true );
if ( JSON_ERROR_NONE === json_last_error() ) {
// Save to database.
$this->save_image_recommendations( $post_id, $image_specs['images'] );
return $image_specs;
}
}
return new WP_Error( 'parse_error', 'Failed to parse image prompts' );
}
/**
* Get prompt guidance for specific image model.
*
* @param string $image_model Image model ID.
* @return array Model configuration.
*/
private function get_prompt_guidance_for_model( $image_model ) {
$model_configs = array(
'black-forest-labs/flux.2-klein' => array(
'model_name' => 'FLUX.2 [klein]',
'prompt_length' => '1-2 sentences',
'complexity' => 'simple',
'guidance' => 'Keep prompts short and simple. Focus on main subject, key details, and style. Avoid complex scenes or technical specifications.',
'template' => 'Subject, key elements, style, color palette',
),
'sourceful/riverflow-v2-max' => array(
'model_name' => 'Riverflow V2 Max',
'prompt_length' => '3-4 sentences',
'complexity' => 'medium-detailed',
'guidance' => 'Include context, environment details, lighting style, and photographic specifications. Model excels at photorealism.',
'template' => 'Subject + context, environment details, lighting style, photography style, technical specs',
),
'black-forest-labs/flux.2-max' => array(
'model_name' => 'FLUX.2 [max]',
'prompt_length' => '4-6 sentences',
'complexity' => 'very-detailed-technical',
'guidance' => 'Use detailed technical vocabulary. Include exact materials, color codes (HEX), spatial relationships, and specifications.',
'template' => 'Technical foundation, main subject + action, environment, lighting + mood, style + aesthetics, technical specifications',
),
);
// Default to Riverflow if model not found.
return $model_configs[ $image_model ] ?? $model_configs['sourceful/riverflow-v2-max'];
}
/**
* Save image recommendations to database.
*
* @param int $post_id Post ID.
* @param array $images Image specifications.
*/
private function save_image_recommendations( $post_id, $images ) {
global $wpdb;
$table = $wpdb->prefix . 'wpaw_images';
foreach ( $images as $image_spec ) {
$wpdb->insert(
$table,
array(
'post_id' => $post_id,
'agent_image_id' => $image_spec['agent_image_id'],
'placement' => $image_spec['placement'],
'section_title' => $image_spec['section_title'],
'prompt_initial' => $image_spec['prompt'],
'alt_text_initial' => $image_spec['alt'],
'image_model' => $image_spec['image_model'],
'status' => 'pending',
),
array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
);
}
}
/**
* Save single image recommendation to database.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Unique image identifier.
* @param string $placement Placement location.
* @param string $section_title Section title.
* @param string $prompt Image prompt/description.
* @param string $alt_text Alt text for image.
* @return int|false Insert ID or false on failure.
*/
public function save_image_recommendation( $post_id, $agent_image_id, $placement, $section_title, $prompt, $alt_text ) {
global $wpdb;
$table = $wpdb->prefix . 'wpaw_images';
$settings = get_option( 'wp_agentic_writer_settings', array() );
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
$result = $wpdb->insert(
$table,
array(
'post_id' => $post_id,
'agent_image_id' => $agent_image_id,
'placement' => $placement,
'section_title' => $section_title,
'prompt_initial' => $prompt,
'alt_text_initial' => $alt_text,
'image_model' => $image_model,
'status' => 'pending',
),
array( '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
);
if ( $result ) {
return $wpdb->insert_id;
}
return false;
}
/**
* Get image recommendations for a post.
*
* @param int $post_id Post ID.
* @return array Image recommendations.
*/
public function get_image_recommendations( $post_id ) {
global $wpdb;
$table = $wpdb->prefix . 'wpaw_images';
$results = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table} WHERE post_id = %d ORDER BY created_at ASC",
$post_id
),
ARRAY_A
);
return $results;
}
/**
* Generate image variants.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param string $prompt Image prompt.
* @param int $variant_count Number of variants to generate.
* @return array|WP_Error Generated variants or error.
*/
public function generate_image_variants( $post_id, $agent_image_id, $prompt, $variant_count = 2 ) {
$settings = get_option( 'wp_agentic_writer_settings', array() );
$image_model = $settings['image_model'] ?? 'openai/gpt-4o';
$provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'image' );
$variants = array();
for ( $i = 1; $i <= $variant_count; $i++ ) {
$result = $provider->generate_image(
$prompt,
$image_model,
array(
'size' => '1024x576',
'quality' => 'hd',
'n' => 1,
)
);
if ( is_wp_error( $result ) ) {
return $result;
}
// Download image to temp directory.
$temp_file = $this->download_temp_image( $post_id, $agent_image_id, $result['url'], $i );
if ( is_wp_error( $temp_file ) ) {
return $temp_file;
}
// Save variant to database.
$variant_id = $this->save_variant(
$post_id,
$agent_image_id,
$i,
$temp_file,
$prompt,
$image_model,
$result
);
$variants[] = array(
'id' => $variant_id,
'variant_number' => $i,
'temp_file_url' => $temp_file['url'],
'cost' => $result['cost'],
'generation_time' => $result['generation_time'],
'image_model_used' => $image_model,
);
}
return $variants;
}
/**
* Download image to temp directory.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param string $image_url Image URL.
* @param int $variant_number Variant number.
* @return array|WP_Error File info or error.
*/
private function download_temp_image( $post_id, $agent_image_id, $image_url, $variant_number ) {
$upload_dir = wp_upload_dir();
$temp_dir = $upload_dir['basedir'] . '/wpaw/' . $post_id;
if ( ! file_exists( $temp_dir ) ) {
wp_mkdir_p( $temp_dir );
}
// Download image.
$response = wp_remote_get( $image_url, array( 'timeout' => 30 ) );
if ( is_wp_error( $response ) ) {
return $response;
}
$image_data = wp_remote_retrieve_body( $response );
// Determine file extension from content type.
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
$extension = 'jpg';
if ( strpos( $content_type, 'png' ) !== false ) {
$extension = 'png';
}
$filename = sprintf(
'%s_variant_%d_%d.%s',
$agent_image_id,
$variant_number,
time(),
$extension
);
$file_path = $temp_dir . '/' . $filename;
file_put_contents( $file_path, $image_data );
$file_url = $upload_dir['baseurl'] . '/wpaw/' . $post_id . '/' . $filename;
return array(
'path' => $file_path,
'url' => $file_url,
'size' => filesize( $file_path ),
);
}
/**
* Save variant to database.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param int $variant_number Variant number.
* @param array $temp_file Temp file info.
* @param string $prompt Prompt used.
* @param string $image_model Image model used.
* @param array $generation_result Generation result.
* @return int Variant ID.
*/
private function save_variant( $post_id, $agent_image_id, $variant_number, $temp_file, $prompt, $image_model, $generation_result ) {
global $wpdb;
// Get agentic_image_id from wp_wpaw_images.
$table_images = $wpdb->prefix . 'wpaw_images';
$agentic_image_id = $wpdb->get_var(
$wpdb->prepare(
"SELECT id FROM {$table_images} WHERE post_id = %d AND agent_image_id = %s",
$post_id,
$agent_image_id
)
);
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
$wpdb->insert(
$table_variants,
array(
'agentic_image_id' => $agentic_image_id,
'post_id' => $post_id,
'agent_image_id' => $agent_image_id,
'variant_number' => $variant_number,
'temp_file_path' => $temp_file['path'],
'temp_file_url' => $temp_file['url'],
'file_size' => $temp_file['size'],
'prompt_used' => $prompt,
'image_model_used' => $image_model,
'generation_time' => $generation_result['generation_time'],
'cost' => $generation_result['cost'],
'status' => 'temp',
),
array( '%d', '%d', '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%d', '%f', '%s' )
);
return $wpdb->insert_id;
}
/**
* Commit image variant to WordPress Media Library.
*
* @param int $post_id Post ID.
* @param string $agent_image_id Agent image ID.
* @param int $variant_id Variant ID.
* @param string $alt_text Alt text.
* @return array|WP_Error Attachment info or error.
*/
public function commit_image_variant( $post_id, $agent_image_id, $variant_id, $alt_text ) {
global $wpdb;
// Get variant info.
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
$variant = $wpdb->get_row(
$wpdb->prepare(
"SELECT * FROM {$table_variants} WHERE id = %d",
$variant_id
),
ARRAY_A
);
if ( ! $variant ) {
return new WP_Error( 'variant_not_found', 'Variant not found' );
}
// Upload to Media Library.
require_once ABSPATH . 'wp-admin/includes/image.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
$file_array = array(
'name' => basename( $variant['temp_file_path'] ),
'tmp_name' => $variant['temp_file_path'],
);
$attachment_id = media_handle_sideload( $file_array, $post_id );
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
// Set alt text.
update_post_meta( $attachment_id, '_wp_attachment_image_alt', sanitize_text_field( $alt_text ) );
// Update wp_wpaw_images table.
$table_images = $wpdb->prefix . 'wpaw_images';
$wpdb->update(
$table_images,
array(
'attachment_id' => $attachment_id,
'status' => 'committed',
),
array(
'post_id' => $post_id,
'agent_image_id' => $agent_image_id,
),
array( '%d', '%s' ),
array( '%d', '%s' )
);
// Mark variant as selected.
$wpdb->update(
$table_variants,
array(
'is_selected' => 1,
'selected_at' => current_time( 'mysql' ),
'status' => 'selected',
),
array( 'id' => $variant_id ),
array( '%d', '%s', '%s' ),
array( '%d' )
);
$attachment_url = wp_get_attachment_url( $attachment_id );
return array(
'attachment_id' => $attachment_id,
'attachment_url' => $attachment_url,
'alt' => $alt_text,
);
}
/**
* Cleanup old temp images (7+ days old).
*/
public function cleanup_old_temp_images() {
global $wpdb;
$table_variants = $wpdb->prefix . 'wpaw_images_variants';
// Get temp images older than 7 days.
$old_variants = $wpdb->get_results(
$wpdb->prepare(
"SELECT * FROM {$table_variants}
WHERE status = 'temp'
AND created_at < DATE_SUB(NOW(), INTERVAL %d DAY)",
7
),
ARRAY_A
);
foreach ( $old_variants as $variant ) {
// Delete file.
if ( file_exists( $variant['temp_file_path'] ) ) {
unlink( $variant['temp_file_path'] );
}
// Update status.
$wpdb->update(
$table_variants,
array(
'status' => 'auto_deleted',
'deleted_at' => current_time( 'mysql' ),
),
array( 'id' => $variant['id'] ),
array( '%s', '%s' ),
array( '%d' )
);
}
}
}