53 KiB
WP Agentic Writer: Image Generation Implementation Plan
Document Version: 1.1
Date: January 28, 2026
Status: Ready for Implementation
Estimated Time: 16-20 hours
Changelog v1.1:
- Updated table names to use
wp_wpaw_prefix (consistent with existing tables) - Changed temp folder location to
/wp-content/uploads/wpaw/{post_id}/ - Added user-controlled variant count setting (1-3 variants per image)
Executive Summary
This document provides a comprehensive implementation plan for adding AI-powered image generation to WP Agentic Writer. The implementation follows Option A (Cost-Optimized with User Control) from the recommendations, ensuring maximum cost efficiency and quality control.
Key Features
- ✅ Cost-optimized flow: Analysis + prompts cost ~$0.002, user controls image generation
- ✅ Model-specific prompts: Tailored for FLUX.2 klein (Budget), Riverflow V2 Max (Balanced), FLUX.2 max (Premium)
- ✅ Variant management: User controls variant count (1-3), selects best one
- ✅ WordPress integration: Direct upload to Media Library with alt text
- ✅ Temp file management: Automatic cleanup of unused variants
Table of Contents
- Current State Analysis
- Architecture Overview
- Database Schema
- Implementation Phases
- Phase 1: Database & Core Infrastructure
- Phase 2: Backend Image Generation
- Phase 3: Frontend UI & Modal
- Phase 4: Gutenberg Integration
- Phase 5: Temp File Management
- Phase 6: Testing & Polish
- Configuration & Settings
- Cost Tracking Integration
- Security Considerations
- Rollout Strategy
Current State Analysis
✅ What We Have
-
Image Model Configuration
- Settings page has image model selector
- Default:
openai/gpt-4o - Preset support: Budget/Balanced/Premium all use GPT-4o
- Model stored in
wp_agentic_writer_settings['image_model']
-
Image Placeholder Support
- Writing agent can suggest images using
[IMAGE: description]format - Markdown parser converts
[IMAGE: ...]tocore/imageblocks - Post config has
include_imagesboolean flag
- Writing agent can suggest images using
-
OpenRouter Provider
- Has
generate_image()method (basic implementation) - Uses chat completion for images (not optimal)
- No variant support, no temp file management
- Has
-
Cost Tracking
- Existing table:
wp_wpaw_cost_tracking - Tracks model, action, tokens, cost per request
- Can be extended for image generation costs
- Existing table:
❌ What We Need
-
Database Tables
wp_wpaw_images- Image recommendations and metadatawp_wpaw_images_variants- Generated image variants
-
Image Generation Flow
- Analyze article for placement
- Generate model-specific prompts
- Generate image variants via OpenRouter
- Download and store temp files
- Upload selected variant to WordPress Media
-
Frontend UI
- Image review modal after article generation
- Variant selection interface
- Gutenberg block toolbar integration
- Progress indicators
-
File Management
- Temp directory:
/wp-content/uploads/wpaw/{post_id}/ - Automatic cleanup (7+ days)
- Cron job for maintenance
- Temp directory:
-
User Controls
- Variant count selector (1-3 variants per image)
- Cost preview before generation
- Per-image generation control
-
Model-Specific Prompting
- FLUX.2 klein: Simple 1-2 sentence prompts
- Riverflow V2 Max: Detailed 3-4 sentence prompts
- FLUX.2 max: Complex 4-6 sentence prompts
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ ARTICLE GENERATION COMPLETE │
│ (Writing agent finishes, returns markdown with [IMAGE: ...])│
└────────────────┬────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ AUTOMATIC: Analyze & Generate Prompts │
│ • analyze_article_for_images() → placement points │
│ • generate_image_prompts() → 3 image specs │
│ • Store in wp_wpaw_images table │
│ Cost: ~$0.002 (uses writing model) │
└────────────────┬────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND: Image Review Modal │
│ • Show 3 image recommendations │
│ • Display prompts (editable) │
│ • Display alt text (editable) │
│ • Variant count selector (1-3 per image) │
│ • Show cost estimate per image × variant count │
│ • [Generate All] [Generate Selected] [Skip] │
└────────────────┬────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ USER SELECTS: Generate Image(s) │
│ • Choose variant count (1-3) per image │
│ • Click [Generate] on individual image │
│ • OR click [Generate All] │
└────────────────┬────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ BACKEND: Generate Variants │
│ • Call OpenRouter image generation API │
│ • Generate N variants (user-specified: 1-3) │
│ • Download to /wp-content/uploads/wpaw/{post_id}/ │
│ • Store in wp_wpaw_images_variants table │
│ • Return variant URLs to frontend │
└────────────────┬────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND: Variant Selection Modal │
│ • Display all variants in grid │
│ • Show generation info (cost, time, model) │
│ • [Select] button per variant │
│ • [Regenerate] for more variants │
└────────────────┬────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ BACKEND: Commit to WordPress Media │
│ • media_handle_sideload() - upload to Media Library │
│ • Set alt text from recommendation │
│ • Update wp_wpaw_images: status='committed' │
│ • Return attachment ID + URL │
└────────────────┬────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ FRONTEND: Update Gutenberg Block │
│ • updateBlockAttributes() with attachment ID/URL │
│ • Remove data-agent-image-id placeholder marker │
│ • Image now permanent in WordPress │
└─────────────────────────────────────────────────────────────┘
Database Schema
Table 1: wp_wpaw_images
Stores image recommendations from the writing agent.
CREATE TABLE IF NOT EXISTS `wp_wpaw_images` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`post_id` bigint(20) NOT NULL,
-- Recommendation from agent
`agent_image_id` varchar(50) NOT NULL, -- e.g., "img_hero_1"
`placement` varchar(100) DEFAULT NULL, -- "intro_hero", "after_section_2"
`section_title` varchar(255) DEFAULT NULL, -- "Introduction to n8n"
-- Original recommendation
`prompt_initial` text NOT NULL, -- Agent's initial prompt
`alt_text_initial` text DEFAULT NULL, -- Agent's suggested alt
-- User edits (nullable)
`prompt_edited` text DEFAULT NULL, -- NULL if user didn't edit
`alt_text_edited` text DEFAULT NULL, -- NULL if user didn't edit
-- Committed image (when user selects a variant)
`attachment_id` bigint(20) DEFAULT NULL, -- WP attachment ID (null until committed)
`status` varchar(30) DEFAULT 'pending', -- pending, generating, committed, discarded
-- Cost tracking
`cost_estimate` decimal(10, 4) DEFAULT NULL, -- Based on image model pricing
`cost_actual` decimal(10, 4) DEFAULT NULL, -- Updated after generation
`image_model` varchar(100) DEFAULT NULL, -- Which model was used
-- Metadata
`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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Table 2: wp_wpaw_images_variants
Tracks all generated variants (temp images) for each agent_image_id.
CREATE TABLE IF NOT EXISTS `wp_wpaw_images_variants` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
-- Reference to main image record
`agentic_image_id` bigint(20) NOT NULL,
`post_id` bigint(20) NOT NULL,
`agent_image_id` varchar(50) NOT NULL, -- e.g., "img_hero_1"
-- Variant details
`variant_number` int(11) DEFAULT 1, -- 1st, 2nd, 3rd generation attempt
`temp_file_path` varchar(500) NOT NULL, -- /wp-content/uploads/wpaw/{post_id}/xxx.jpg
`temp_file_url` varchar(500) NOT NULL, -- URL to temp image
`file_size` int(11) DEFAULT NULL, -- In bytes
-- Generation details
`prompt_used` text DEFAULT NULL, -- Exact prompt sent to image model
`image_model_used` varchar(100) DEFAULT NULL, -- Which model generated this
`generation_time` int(11) DEFAULT NULL, -- Seconds to generate
`cost` decimal(10, 4) DEFAULT NULL, -- Cost of this generation
-- Selection status
`is_selected` tinyint(1) DEFAULT 0, -- 1 if user selected this variant
`selected_at` datetime DEFAULT NULL,
-- Lifecycle
`status` varchar(30) DEFAULT 'temp', -- temp, selected, discarded, auto_deleted
`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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Implementation Phases
Phase 1: Database & Core Infrastructure (2-3 hours)
- Create database tables
- Create temp directory structure
- Add activation/deactivation hooks
Phase 2: Backend Image Generation (4-5 hours)
- Implement image analysis
- Implement prompt generation (model-specific)
- Implement OpenRouter image generation
- Implement variant management
- Implement Media Library upload
Phase 3: Frontend UI & Modal (4-5 hours)
- Image review modal
- Variant selection interface
- Progress indicators
- Error handling
Phase 4: Gutenberg Integration (2-3 hours)
- Block toolbar button
- Block attribute updates
- Placeholder management
Phase 5: Temp File Management (2 hours)
- Cleanup cron job
- Admin page for temp images
- Manual cleanup interface
Phase 6: Testing & Polish (2-3 hours)
- End-to-end testing
- Error scenarios
- Performance optimization
- Documentation
Total Estimated Time: 16-21 hours
Phase 1: Database & Core Infrastructure
1.1 Create Database Tables
File: includes/class-image-manager.php (NEW)
<?php
/**
* Image Manager Class
*
* Handles image generation, variant management, and WordPress Media integration.
*
* @package WP_Agentic_Writer
*/
class WP_Agentic_Writer_Image_Manager {
/**
* Singleton instance.
*/
private static $instance = null;
/**
* Get singleton instance.
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor.
*/
private function __construct() {
// Register activation hook
register_activation_hook( WP_AGENTIC_WRITER_FILE, array( $this, 'create_tables' ) );
}
/**
* 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" );
}
}
}
}
1.2 Update Plugin Activation
File: wp-agentic-writer.php
// Add to plugin initialization
function wp_agentic_writer_activate() {
// Existing activation code...
// Initialize image manager (creates tables)
WP_Agentic_Writer_Image_Manager::get_instance()->create_tables();
}
register_activation_hook( __FILE__, 'wp_agentic_writer_activate' );
1.3 Update Autoloader
File: includes/class-autoloader.php
// Add to class map
'WP_Agentic_Writer_Image_Manager' => 'class-image-manager.php',
Phase 2: Backend Image Generation
2.1 Analyze Article for Image Placement
File: includes/class-image-manager.php (add methods)
/**
* 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_OpenRouter_Provider::get_instance();
$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_last_error() === JSON_ERROR_NONE ) {
return $placement_data;
}
}
return new WP_Error( 'parse_error', 'Failed to parse placement analysis' );
}
2.2 Generate Model-Specific Prompts
/**
* 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 = 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_OpenRouter_Provider::get_instance();
$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_last_error() === JSON_ERROR_NONE ) {
// 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.
*/
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.
*/
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' )
);
}
}
2.3 Generate Image via OpenRouter
File: includes/class-openrouter-provider.php (update existing method)
/**
* Generate image using OpenRouter image generation API.
*
* @param string $prompt Image prompt.
* @param string $model Image model (optional, uses default if not provided).
* @param array $options Additional options (size, quality, etc).
* @return array|WP_Error Response with image URL or error.
*/
public function generate_image( $prompt, $model = null, $options = array() ) {
if ( empty( $this->api_key ) ) {
return new WP_Error( 'no_api_key', 'OpenRouter API key not configured' );
}
$model = $model ?? $this->image_model;
$size = $options['size'] ?? '1024x576'; // Default blog size
$quality = $options['quality'] ?? 'hd';
$n = $options['n'] ?? 1; // Number of variants
$start_time = microtime( true );
$response = wp_remote_post(
'https://openrouter.ai/api/v1/images/generations',
array(
'headers' => array(
'Authorization' => 'Bearer ' . $this->api_key,
'Content-Type' => 'application/json',
'HTTP-Referer' => home_url(),
'X-Title' => get_bloginfo( 'name' ),
),
'body' => wp_json_encode( array(
'model' => $model,
'prompt' => $prompt,
'n' => $n,
'size' => $size,
'quality' => $quality,
) ),
'timeout' => 60,
)
);
$generation_time = microtime( true ) - $start_time;
if ( is_wp_error( $response ) ) {
return $response;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! isset( $body['data'][0]['url'] ) ) {
return new WP_Error(
'image_generation_failed',
$body['error']['message'] ?? 'Unknown error'
);
}
return array(
'url' => $body['data'][0]['url'],
'cost' => $body['usage']['cost'] ?? 0.03, // Fallback estimate
'generation_time' => $generation_time,
'model' => $model,
);
}
2.4 REST API Endpoints
File: includes/class-gutenberg-sidebar.php (add to register_routes())
// Image generation endpoints
register_rest_route(
'wp-agentic-writer/v1',
'/analyze-images',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_analyze_images' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/generate-image',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_generate_image' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/commit-image',
array(
'methods' => 'POST',
'callback' => array( $this, 'handle_commit_image' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
register_rest_route(
'wp-agentic-writer/v1',
'/image-recommendations/(?P<post_id>\d+)',
array(
'methods' => 'GET',
'callback' => array( $this, 'handle_get_image_recommendations' ),
'permission_callback' => array( $this, 'check_permissions' ),
)
);
Phase 3: Frontend UI & Modal
3.1 Image Review Modal Component
File: assets/js/image-modal.js (NEW)
/**
* Image Generation Modal Component
*
* Handles image review, generation, variant selection, and commitment.
*/
(function() {
const { Modal, Button, Spinner, TextControl, TextareaControl } = wp.components;
const { useState, useEffect } = wp.element;
window.wpAgenticWriter = window.wpAgenticWriter || {};
/**
* Image Review Modal
* Shows after article generation with image recommendations
*/
window.wpAgenticWriter.ImageReviewModal = function({ postId, onClose, onComplete }) {
const [step, setStep] = useState('loading'); // loading, review, generating, selecting
const [images, setImages] = useState([]);
const [selectedImages, setSelectedImages] = useState([]);
const [variantCounts, setVariantCounts] = useState({}); // Track variant count per image
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState(null);
// Load image recommendations
useEffect(() => {
loadImageRecommendations();
}, []);
const loadImageRecommendations = async () => {
try {
const response = await fetch(
`${wpAgenticWriter.apiUrl}/image-recommendations/${postId}`,
{
headers: {
'X-WP-Nonce': wpAgenticWriter.nonce,
},
}
);
if (!response.ok) {
throw new Error('Failed to load image recommendations');
}
const data = await response.json();
const imgs = data.images || [];
setImages(imgs);
// Initialize variant counts to 2 for each image
const initialCounts = {};
imgs.forEach(img => {
initialCounts[img.agent_image_id] = 2; // Default: 2 variants
});
setVariantCounts(initialCounts);
setStep('review');
} catch (err) {
setError(err.message);
setStep('review');
}
};
const handleEditPrompt = (imageId, newPrompt) => {
setImages(prev => prev.map(img =>
img.agent_image_id === imageId
? { ...img, prompt_edited: newPrompt }
: img
));
};
const handleEditAlt = (imageId, newAlt) => {
setImages(prev => prev.map(img =>
img.agent_image_id === imageId
? { ...img, alt_text_edited: newAlt }
: img
));
};
const handleVariantCountChange = (imageId, count) => {
setVariantCounts(prev => ({
...prev,
[imageId]: parseInt(count, 10)
}));
};
const calculateTotalCost = () => {
const settings = wpAgenticWriter.settings || {};
const imageModel = settings.image_model || 'sourceful/riverflow-v2-max';
// Cost per image by model (estimates)
const costPerImage = {
'black-forest-labs/flux.2-klein': 0.02,
'sourceful/riverflow-v2-max': 0.03,
'black-forest-labs/flux.2-max': 0.15,
};
const baseCost = costPerImage[imageModel] || 0.03;
let total = 0;
selectedImages.forEach(imageId => {
const count = variantCounts[imageId] || 1;
total += baseCost * count;
});
return total.toFixed(3);
};
const handleGenerateSelected = async () => {
if (selectedImages.length === 0) {
alert('Please select at least one image to generate');
return;
}
setIsGenerating(true);
setStep('generating');
try {
for (const imageId of selectedImages) {
const image = images.find(img => img.agent_image_id === imageId);
const response = await fetch(
`${wpAgenticWriter.apiUrl}/generate-image`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
post_id: postId,
agent_image_id: imageId,
prompt: image.prompt_edited || image.prompt_initial,
alt: image.alt_text_edited || image.alt_text_initial,
variant_count: variantCounts[imageId] || 1,
}),
}
);
if (!response.ok) {
throw new Error(`Failed to generate image: ${imageId}`);
}
const result = await response.json();
// Update image with variants
setImages(prev => prev.map(img =>
img.agent_image_id === imageId
? { ...img, variants: result.variants }
: img
));
}
setStep('selecting');
} catch (err) {
setError(err.message);
setStep('review');
} finally {
setIsGenerating(false);
}
};
const handleSelectVariant = async (imageId, variantId) => {
const image = images.find(img => img.agent_image_id === imageId);
try {
const response = await fetch(
`${wpAgenticWriter.apiUrl}/commit-image`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
post_id: postId,
agent_image_id: imageId,
variant_id: variantId,
alt: image.alt_text_edited || image.alt_text_initial,
}),
}
);
if (!response.ok) {
throw new Error('Failed to commit image');
}
const result = await response.json();
// Update Gutenberg block
updateGutenbergBlock(imageId, result);
// Mark as committed
setImages(prev => prev.map(img =>
img.agent_image_id === imageId
? { ...img, status: 'committed', attachment_id: result.attachment_id }
: img
));
} catch (err) {
alert('Failed to commit image: ' + err.message);
}
};
const updateGutenbergBlock = (agentImageId, attachmentData) => {
// Find block with matching data-agent-image-id
const blocks = wp.data.select('core/block-editor').getBlocks();
const findAndUpdateBlock = (blocks) => {
for (const block of blocks) {
if (block.name === 'core/image' &&
block.attributes['data-agent-image-id'] === agentImageId) {
wp.data.dispatch('core/block-editor').updateBlockAttributes(
block.clientId,
{
id: attachmentData.attachment_id,
url: attachmentData.attachment_url,
alt: attachmentData.alt,
'data-agent-image-id': undefined, // Remove placeholder marker
}
);
return true;
}
if (block.innerBlocks && block.innerBlocks.length > 0) {
if (findAndUpdateBlock(block.innerBlocks)) {
return true;
}
}
}
return false;
};
findAndUpdateBlock(blocks);
};
// Render functions for each step
if (step === 'loading') {
return wp.element.createElement(Modal, {
title: 'Loading Image Recommendations',
onRequestClose: onClose,
},
wp.element.createElement('div', { style: { padding: '20px', textAlign: 'center' } },
wp.element.createElement(Spinner)
)
);
}
if (step === 'review') {
return wp.element.createElement(Modal, {
title: `Image Recommendations (${images.length})`,
onRequestClose: onClose,
style: { maxWidth: '800px' },
},
wp.element.createElement('div', { className: 'wpaw-image-review' },
error && wp.element.createElement('div', {
className: 'notice notice-error',
style: { marginBottom: '20px' }
}, error),
images.map(image =>
wp.element.createElement('div', {
key: image.agent_image_id,
className: 'wpaw-image-card',
style: {
border: '1px solid #ddd',
padding: '15px',
marginBottom: '15px',
borderRadius: '4px',
},
},
wp.element.createElement('h3', null,
`Image: ${image.section_title || image.placement}`
),
wp.element.createElement(TextareaControl, {
label: 'Prompt',
value: image.prompt_edited || image.prompt_initial,
onChange: (value) => handleEditPrompt(image.agent_image_id, value),
rows: 3,
}),
wp.element.createElement(TextControl, {
label: 'Alt Text',
value: image.alt_text_edited || image.alt_text_initial,
onChange: (value) => handleEditAlt(image.agent_image_id, value),
}),
wp.element.createElement('div', {
style: { marginTop: '10px', marginBottom: '10px' }
},
wp.element.createElement('label', {
style: { display: 'block', marginBottom: '5px', fontWeight: '600' }
}, 'Variant Count'),
wp.element.createElement('select', {
value: variantCounts[image.agent_image_id] || 2,
onChange: (e) => handleVariantCountChange(image.agent_image_id, e.target.value),
style: {
padding: '5px',
borderRadius: '3px',
border: '1px solid #ddd',
}
},
wp.element.createElement('option', { value: '1' }, '1 variant'),
wp.element.createElement('option', { value: '2' }, '2 variants'),
wp.element.createElement('option', { value: '3' }, '3 variants')
),
wp.element.createElement('p', {
style: { fontSize: '12px', color: '#666', margin: '5px 0 0' }
}, `Cost: ~$${((variantCounts[image.agent_image_id] || 2) * 0.03).toFixed(3)}`)
),
wp.element.createElement('label', null,
wp.element.createElement('input', {
type: 'checkbox',
checked: selectedImages.includes(image.agent_image_id),
onChange: (e) => {
if (e.target.checked) {
setSelectedImages(prev => [...prev, image.agent_image_id]);
} else {
setSelectedImages(prev => prev.filter(id => id !== image.agent_image_id));
}
},
}),
' Generate this image'
)
)
),
wp.element.createElement('div', {
style: {
marginTop: '20px',
display: 'flex',
gap: '10px',
justifyContent: 'flex-end',
}
},
wp.element.createElement(Button, {
variant: 'secondary',
onClick: onClose,
}, 'Skip Images'),
wp.element.createElement(Button, {
variant: 'primary',
onClick: handleGenerateSelected,
disabled: selectedImages.length === 0 || isGenerating,
}, `Generate ${selectedImages.length} Image(s) (~$${calculateTotalCost()})`)
)
)
);
}
if (step === 'generating') {
return wp.element.createElement(Modal, {
title: 'Generating Images',
onRequestClose: () => {},
},
wp.element.createElement('div', { style: { padding: '20px', textAlign: 'center' } },
wp.element.createElement(Spinner),
wp.element.createElement('p', null,
`Generating images... This may take a minute.`
),
wp.element.createElement('p', { style: { fontSize: '12px', color: '#666' } },
`Estimated cost: $${calculateTotalCost()}`
)
)
);
}
if (step === 'selecting') {
return wp.element.createElement(Modal, {
title: 'Select Image Variants',
onRequestClose: onClose,
style: { maxWidth: '900px' },
},
wp.element.createElement('div', { className: 'wpaw-variant-selection' },
images
.filter(img => img.variants && img.variants.length > 0)
.map(image =>
wp.element.createElement('div', {
key: image.agent_image_id,
style: { marginBottom: '30px' },
},
wp.element.createElement('h3', null, image.section_title),
wp.element.createElement('div', {
style: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '15px',
},
},
image.variants.map(variant =>
wp.element.createElement('div', {
key: variant.id,
style: {
border: '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden',
},
},
wp.element.createElement('img', {
src: variant.temp_file_url,
alt: 'Variant',
style: { width: '100%', display: 'block' },
}),
wp.element.createElement('div', { style: { padding: '10px' } },
wp.element.createElement('p', { style: { fontSize: '12px', margin: '0 0 10px' } },
`Cost: $${variant.cost.toFixed(3)} • ${variant.generation_time}s`
),
wp.element.createElement(Button, {
variant: 'primary',
onClick: () => handleSelectVariant(image.agent_image_id, variant.id),
style: { width: '100%' },
}, 'Select')
)
)
)
)
)
),
wp.element.createElement('div', {
style: { marginTop: '20px', textAlign: 'right' },
},
wp.element.createElement(Button, {
variant: 'secondary',
onClick: onComplete,
}, 'Done')
)
)
);
}
};
})();
Configuration & Settings
Update Model Presets
File: includes/class-settings.php and includes/class-settings-v2.php
Update presets to use recommended image models:
$presets = 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' => 'black-forest-labs/flux.2-klein', // UPDATED
),
'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' => 'sourceful/riverflow-v2-max', // UPDATED
),
'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' => 'black-forest-labs/flux.2-max', // UPDATED
),
);
Cost Tracking Integration
Update cost tracking to include image generation:
// In class-cost-tracker.php
public function track_image_generation( $post_id, $image_model, $cost, $generation_time ) {
global $wpdb;
$wpdb->insert(
$wpdb->prefix . 'wpaw_cost_tracking',
array(
'post_id' => $post_id,
'model' => $image_model,
'action' => 'image_generation',
'input_tokens' => 0, // Images don't use tokens
'output_tokens' => 0,
'cost' => $cost,
),
array( '%d', '%s', '%s', '%d', '%d', '%f' )
);
}
Security Considerations
-
File Upload Security
- Validate image file types (JPEG, PNG only)
- Sanitize filenames
- Check file size limits
- Use WordPress media_handle_sideload()
-
Permission Checks
- Verify user can edit post
- Check nonces on all AJAX requests
- Validate post ownership
-
Temp Directory
- Add .htaccess to prevent direct access
- Regular cleanup of old files
- Limit directory size
-
API Security
- Never expose API keys to frontend
- Rate limiting on image generation
- Cost limits per user/post
Rollout Strategy
Phase 1: Beta (Week 1)
- Enable for admin users only
- Test with Budget preset
- Monitor costs and errors
Phase 2: Limited Release (Week 2)
- Enable for all users
- Add usage limits (e.g., 10 images/day)
- Collect feedback
Phase 3: Full Release (Week 3)
- Remove limits
- Add admin page for image management
- Documentation and tutorials
Testing Checklist
- Database tables created successfully
- Temp directory created with proper permissions
- Article analysis generates placement points
- Prompt generation creates model-specific prompts
- Image generation via OpenRouter works
- Variants saved to temp directory
- Variant selection updates Gutenberg block
- Media Library upload works with alt text
- Cost tracking records image generation
- Cleanup cron job removes old temps
- Error handling for API failures
- Permission checks work correctly
- UI responsive on mobile
- Works with all 3 presets
Success Metrics
- Cost Efficiency: Average cost per article with images < $0.15
- User Adoption: >50% of users generate at least 1 image
- Quality: <10% regeneration rate (first variant selected)
- Performance: Image generation completes in <30 seconds
- Errors: <5% API failure rate
Next Steps
- Review this plan with team
- Create Phase 1 branch in git
- Implement database schema (2 hours)
- Build backend API (4 hours)
- Create frontend modal (4 hours)
- Test end-to-end (2 hours)
- Deploy to staging for beta testing
End of Implementation Plan