Files
wp-agentic-writer/image-gen-flow.md
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

46 KiB

WP Agentic Writer: Image Generation & Selection Flow

Executive Summary

This document defines the complete lifecycle of images from agent recommendation → generation → variant management → final WordPress Media upload.

Key principle: Regenerate creates NEW variants (doesn't delete old ones). All temp images belong to a post. Users see variants in modal, select one, and commit to WordPress Media with recommended alt text.


Table of Contents

  1. Overview & Architecture
  2. Data Model
  3. Flow 1: Article Generation & Image Recommendations
  4. Flow 2: Image Block Toolbar & Modal
  5. Flow 3: Image Generation (Variants)
  6. Flow 4: Variant Selection & Media Upload
  7. Flow 5: Temp Image Management
  8. Admin Page: Image Library
  9. REST API Endpoints
  10. Implementation Checklist

Overview & Architecture

Core concept

┌─────────────────────────────────────────────────────────────────┐
│ WRITING AGENT generates article + 3 image recommendations      │
└────────────────────┬────────────────────────────────────────────┘
                     ↓
┌─────────────────────────────────────────────────────────────────┐
│ Plugin converts recommendations → 3 core/image blocks           │
│ Each block has: data-agent-image-id="img_X"                    │
│ Stores recommendations in wp_agentic_images table              │
└────────────────────┬────────────────────────────────────────────┘
                     ↓
┌─────────────────────────────────────────────────────────────────┐
│ USER EDITS IN GUTENBERG EDITOR                                  │
│                                                                  │
│ [Image block] [Image block] [Image block]                       │
│   ↓ Generate       ↓ Generate       ↓ Generate                  │
│   ↓ (Toolbar btn)  ↓ (Toolbar btn)  ↓ (Toolbar btn)            │
│                                                                  │
│ Each opens YOUR modal with prompt + alt editable                │
└────────────────────┬────────────────────────────────────────────┘
                     ↓
┌─────────────────────────────────────────────────────────────────┐
│ USER ACTIONS IN MODAL                                            │
│                                                                  │
│ [View Prompt] [Edit Prompt]                                      │
│ [View Alt] [Edit Alt]                                            │
│ [Generate] → generates 1-3 variants                             │
│             stores ALL in /wp-content/agentic-writer-temp/      │
│                                                                  │
│ [Regenerate] → generates MORE variants (doesn't delete old)    │
│                adds to same image_id pool                        │
│                                                                  │
│ [Use Media Library] → opens core media modal                    │
│                      with pre-filled alt suggestion             │
└────────────────────┬────────────────────────────────────────────┘
                     ↓
┌─────────────────────────────────────────────────────────────────┐
│ USER SELECTS A VARIANT                                           │
│                                                                  │
│ [Variant 1] [Variant 2] [Variant 3] [Variant 4]               │
│  [Select]    [Select]    [Select]    [Select]                  │
│                                                                  │
│ Other variants stay in temp folder + DB                         │
└────────────────────┬────────────────────────────────────────────┘
                     ↓
┌─────────────────────────────────────────────────────────────────┐
│ BACKEND: Commit Selected Variant to WP Media                    │
│                                                                  │
│ 1. media_handle_sideload(temp_image_path)                       │
│ 2. Set attachment alt → recommended alt (or user-edited)       │
│ 3. Update wp_agentic_images: status='committed'                │
│                              attachment_id=123                   │
│ 4. Return attachment ID + URL                                   │
└────────────────────┬────────────────────────────────────────────┘
                     ↓
┌─────────────────────────────────────────────────────────────────┐
│ FRONTEND: Update Gutenberg Image Block                           │
│                                                                  │
│ updateBlockAttributes(block.clientId, {                         │
│   id: attachment_id,                                             │
│   url: attachment_url,                                           │
│   alt: recommended_alt                                           │
│ })                                                               │
│                                                                  │
│ Remove data-agent-image-id (no longer a placeholder)           │
└────────────────────────────────────────────────────────────────┘
                     ↓
┌─────────────────────────────────────────────────────────────────┐
│ TEMP IMAGE CLEANUP (Manual + Auto)                              │
│                                                                  │
│ Admin page "Generated Images" tab shows:                         │
│ - All temp images (by post, by status)                          │
│ - [Delete selected] [Auto-cleanup old (>7 days)]               │
│                                                                  │
│ Cron job: wp_schedule_event() → delete temps > 7 days          │
└─────────────────────────────────────────────────────────────────┘

Data Model

Table: wp_agentic_images

Stores recommendations + generation history for each image per post.

CREATE TABLE wp_agentic_images (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    post_id BIGINT NOT NULL,
    
    -- Recommendation from agent
    agent_image_id VARCHAR(50) NOT NULL,      -- e.g., "img_hero_1"
    placement VARCHAR(100),                    -- "intro_hero", "after_section_2"
    section_title VARCHAR(255),                -- "Introduction to n8n"
    
    -- Original recommendation
    prompt_initial TEXT NOT NULL,              -- Agent's initial prompt
    alt_text_initial TEXT,                     -- Agent's suggested alt
    
    -- User edits (nullable)
    prompt_edited TEXT,                        -- Null if user didn't edit
    alt_text_edited TEXT,                      -- Null if user didn't edit
    
    -- Committed image (when user selects a variant)
    attachment_id BIGINT,                      -- WP attachment ID (null until committed)
    status VARCHAR(30) DEFAULT 'pending',      -- pending, generating, committed, discarded
    
    -- Cost tracking
    cost_estimate DECIMAL(10, 4),              -- Based on image model pricing
    cost_actual DECIMAL(10, 4),                -- Updated after generation
    image_model VARCHAR(100),                  -- Which model was used
    
    -- Metadata
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    KEY idx_post (post_id),
    KEY idx_agent_image_id (post_id, agent_image_id),
    KEY idx_status (status),
    KEY idx_created (created_at)
);

Table: wp_agentic_images_variants

Tracks all generated variants (temp images) for each agent_image_id.

CREATE TABLE wp_agentic_images_variants (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    
    -- Reference to main image record
    agentic_image_id BIGINT NOT NULL,
    post_id BIGINT NOT NULL,
    agent_image_id VARCHAR(50) NOT NULL,      -- e.g., "img_hero_1"
    
    -- Variant details
    variant_number INT DEFAULT 1,             -- 1st, 2nd, 3rd generation attempt
    temp_file_path VARCHAR(500) NOT NULL,     -- /wp-content/agentic-writer-temp/xxx.jpg
    temp_file_url VARCHAR(500) NOT NULL,      -- URL to temp image
    file_size INT,                            -- In bytes
    
    -- Generation details
    prompt_used TEXT,                         -- Exact prompt sent to image model
    image_model_used VARCHAR(100),            -- Which model generated this
    generation_time INT,                      -- Seconds to generate
    cost DECIMAL(10, 4),                      -- Cost of this generation
    
    -- Selection status
    is_selected TINYINT DEFAULT 0,            -- 1 if user selected this variant
    selected_at TIMESTAMP NULL,
    
    -- Lifecycle
    status VARCHAR(30) DEFAULT 'temp',        -- temp, selected, discarded, auto_deleted
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP NULL,
    
    KEY idx_agentic_image (agentic_image_id),
    KEY idx_post (post_id),
    KEY idx_status (status),
    KEY idx_created (created_at)
);

File system: Temp images

/wp-content/agentic-writer-temp/
├── post_<POST_ID>/
│   ├── img_hero_1/
│   │   ├── variant_1.jpg
│   │   ├── variant_2.jpg
│   │   └── variant_3.jpg
│   ├── img_diag_1/
│   │   └── variant_1.jpg
│   └── img_section_2/
│       ├── variant_1.jpg
│       └── variant_2.jpg
└── [cleanup cron removes files 7+ days old]

Flow 1: Article Generation & Image Recommendations

What the agent returns

After writing article, agent provides JSON response:

{
  "status": "article_complete",
  "article_blocks": [
    {
      "blockName": "core/paragraph",
      "attrs": {},
      "innerBlocks": [],
      "innerHTML": "<p>Introduction text...</p>"
    },
    {
      "blockName": "core/image",
      "attrs": {
        "id": null,
        "url": null,
        "alt": "",
        "data-agent-image-id": "img_hero_1"
      }
    }
  ],
  "images": [
    {
      "agent_image_id": "img_hero_1",
      "placement": "intro_hero",
      "section_title": "Introduction",
      "prompt": "N8n workflow automation dashboard...",
      "alt": "N8n automation dashboard with workflow nodes",
      "image_model": "sourceful/riverflow-v2-max"
    },
    {
      "agent_image_id": "img_diag_1",
      "placement": "after_section_2",
      "section_title": "How Workflows Run",
      "prompt": "Workflow architecture diagram...",
      "alt": "Workflow trigger-condition-action diagram",
      "image_model": "sourceful/riverflow-v2-max"
    },
    {
      "agent_image_id": "img_section_4",
      "placement": "before_conclusion",
      "section_title": "Real-world Example",
      "prompt": "Developer using N8n dashboard...",
      "alt": "Developer working with N8n automation dashboard",
      "image_model": "sourceful/riverflow-v2-max"
    }
  ]
}

Backend handling

<?php
/**
 * On article completion, save image recommendations
 */
public static function save_article_with_images( $post_id, $agent_response ) {
    // 1. Insert article blocks
    $post_content = wp_json_encode( $agent_response['article_blocks'] );
    wp_update_post([
        'ID' => $post_id,
        'post_content' => $post_content,
        'post_status' => 'draft'
    ]);
    
    // 2. Save each image recommendation
    foreach ( $agent_response['images'] as $image_spec ) {
        self::save_image_recommendation(
            $post_id,
            $image_spec
        );
    }
    
    // 3. Return success message for chat
    return [
        'status' => 'article_complete',
        'post_id' => $post_id,
        'message' => sprintf(
            'Article created. %d image suggestions ready in the Images panel.',
            count( $agent_response['images'] )
        )
    ];
}

/**
 * Store individual image recommendation
 */
private static function save_image_recommendation( $post_id, $image_spec ) {
    global $wpdb;
    
    $wpdb->insert(
        $wpdb->prefix . 'agentic_images',
        [
            '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'
        ],
        [ '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s' ]
    );
}

Flow 2: Image Block Toolbar & Modal

Block toolbar button

In Gutenberg, each core/image block with data-agent-image-id gets a toolbar button:

// registerPlugin('agentic-image-toolbar', {
//   render() {
//     return <ImageBlockToolbar />
//   }
// })

function ImageBlockToolbar() {
  const { selectedBlockClientId } = useSelect(blockEditorStore);
  const block = useSelect(
    select => select(blockEditorStore).getBlock(selectedBlockClientId),
    [selectedBlockClientId]
  );
  
  if (!block || block.name !== 'core/image') return null;
  
  const agentImageId = block.attributes['data-agent-image-id'];
  if (!agentImageId) return null;  // Not an agent placeholder
  
  return (
    <BlockControls>
      <ToolbarButton
        label="Generate Image"
        onClick={() => openImageModal(agentImageId, block)}
        icon="image"
      />
    </BlockControls>
  );
}

/**
 * Opens YOUR custom modal (not WP media modal)
 */
function openImageModal(agentImageId, block) {
  wp.data.dispatch('agentic-writer').openImageGenerationModal({
    agentImageId,
    blockClientId: block.clientId,
    postId: wp.data.select('core/editor').getCurrentPostId()
  });
}

Your custom modal

function ImageGenerationModal({ agentImageId, blockClientId, postId }) {
  const [prompt, setPrompt] = useState(initialPrompt);
  const [alt, setAlt] = useState(initialAlt);
  const [variants, setVariants] = useState([]);
  const [isGenerating, setIsGenerating] = useState(false);
  const [step, setStep] = useState('edit'); // 'edit' | 'generating' | 'select'
  
  const handleGenerate = async () => {
    setIsGenerating(true);
    setStep('generating');
    
    try {
      const response = await fetch('/wp-json/agentic-writer/v1/generate-image', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          post_id: postId,
          agent_image_id: agentImageId,
          prompt: prompt,  // User-edited if changed
          alt: alt         // User-edited if changed
        })
      });
      
      const result = await response.json();
      setVariants(result.variants);
      setStep('select');
    } catch (error) {
      console.error('Generation failed:', error);
    } finally {
      setIsGenerating(false);
    }
  };
  
  const handleRegenerate = async () => {
    // Re-generate: creates MORE variants
    // Does NOT delete existing ones
    await handleGenerate();
  };
  
  const handleSelect = async (variantId) => {
    // Commit this variant to WP Media
    const response = await fetch('/wp-json/agentic-writer/v1/commit-image', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        post_id: postId,
        agent_image_id: agentImageId,
        variant_id: variantId,
        alt: alt  // Final alt text
      })
    });
    
    const result = await response.json();
    
    // Update Gutenberg block
    wp.data.dispatch('core/block-editor').updateBlockAttributes(
      blockClientId,
      {
        id: result.attachment_id,
        url: result.attachment_url,
        alt: result.alt,
        'data-agent-image-id': undefined  // Remove placeholder marker
      }
    );
    
    // Close modal
    wp.data.dispatch('agentic-writer').closeImageGenerationModal();
  };
  
  if (step === 'edit') {
    return (
      <Modal title="Generate Image" onRequestClose={closeModal}>
        <div className="agentic-image-modal">
          <div className="section">
            <label>Prompt:</label>
            <textarea 
              value={prompt} 
              onChange={e => setPrompt(e.target.value)}
              rows={4}
            />
          </div>
          
          <div className="section">
            <label>Alt Text:</label>
            <input 
              type="text"
              value={alt}
              onChange={e => setAlt(e.target.value)}
              placeholder="Alt text for accessibility"
            />
          </div>
          
          <div className="actions">
            <Button
              variant="primary"
              onClick={handleGenerate}
              disabled={isGenerating}
            >
              {isGenerating ? 'Generating...' : 'Generate'}
            </Button>
            <Button variant="secondary" onClick={closeModal}>
              Use Media Library
            </Button>
            <Button variant="tertiary" onClick={closeModal}>
              Cancel
            </Button>
          </div>
        </div>
      </Modal>
    );
  }
  
  if (step === 'generating') {
    return (
      <Modal title="Generating..." onRequestClose={() => {}}>
        <div className="generating-spinner">
          <Spinner /> Generating image variants...
        </div>
      </Modal>
    );
  }
  
  // step === 'select'
  return (
    <Modal title="Select Variant" onRequestClose={closeModal}>
      <div className="variant-grid">
        {variants.map(variant => (
          <div key={variant.id} className="variant-card">
            <img src={variant.temp_file_url} alt="variant" />
            <div className="variant-info">
              <p>Generation: {variant.variant_number}</p>
              <p>Cost: ${variant.cost.toFixed(3)}</p>
              <Button
                variant="primary"
                onClick={() => handleSelect(variant.id)}
              >
                Select
              </Button>
            </div>
          </div>
        ))}
      </div>
      
      <div className="modal-footer">
        <Button variant="secondary" onClick={handleRegenerate}>
          Regenerate (More Variants)
        </Button>
        <Button variant="tertiary" onClick={closeModal}>
          Cancel
        </Button>
      </div>
    </Modal>
  );
}

Flow 3: Image Generation (Variants)

Backend: Generate endpoint

<?php
/**
 * POST /wp-json/agentic-writer/v1/generate-image
 * 
 * Generates 1-3 variants of an image based on prompt.
 * Does NOT delete previous variants.
 */
public static function rest_generate_image( WP_REST_Request $request ) {
    $post_id = $request->get_param('post_id');
    $agent_image_id = $request->get_param('agent_image_id');
    $prompt = $request->get_param('prompt');
    $alt = $request->get_param('alt');
    
    // Validate post ownership
    if (!current_user_can('edit_post', $post_id)) {
        return new WP_Error('unauthorized', 'Not allowed', ['status' => 403]);
    }
    
    // Get image record
    $image_record = self::get_image_record($post_id, $agent_image_id);
    if (!$image_record) {
        return new WP_Error('not_found', 'Image not found', ['status' => 404]);
    }
    
    // Get config (model, preset, etc)
    $image_model = $image_record->image_model;
    $num_variants = apply_filters('agentic_writer_num_variants', 3);  // Default: 3
    
    // Create temp directory for this image
    $temp_dir = self::get_temp_dir_for_image($post_id, $agent_image_id);
    if (!is_dir($temp_dir)) {
        wp_mkdir_p($temp_dir);
    }
    
    // Update status
    self::update_image_record($image_record->id, ['status' => 'generating']);
    
    $variants = [];
    $total_cost = 0;
    
    // Generate N variants (sequentially or in batch)
    for ($i = 1; $i <= $num_variants; $i++) {
        $variant_result = self::generate_single_variant(
            $image_model,
            $prompt,
            $temp_dir,
            $i,
            $post_id,
            $agent_image_id
        );
        
        if (is_wp_error($variant_result)) {
            // Log error but continue (partial success ok)
            error_log("Variant $i failed: " . $variant_result->get_error_message());
            continue;
        }
        
        $variants[] = $variant_result;
        $total_cost += $variant_result['cost'];
    }
    
    // Update main image record
    self::update_image_record($image_record->id, [
        'prompt_edited' => $prompt,  // User's edited prompt (if changed from initial)
        'alt_text_edited' => $alt,   // User's edited alt (if changed from initial)
        'cost_actual' => $total_cost,
        'status' => 'pending'  // Waiting for user to select variant
    ]);
    
    return new WP_REST_Response([
        'success' => true,
        'variants' => $variants,
        'total_cost' => $total_cost,
        'message' => sprintf(
            'Generated %d variants. Total cost: $%.3f',
            count($variants),
            $total_cost
        )
    ]);
}

/**
 * Generate a single variant and save to temp folder
 */
private static function generate_single_variant(
    $image_model,
    $prompt,
    $temp_dir,
    $variant_number,
    $post_id,
    $agent_image_id
) {
    // Get generation time start
    $start_time = microtime(true);
    
    // Call image generation API
    $api_response = self::call_image_api(
        $image_model,
        $prompt
    );
    
    if (is_wp_error($api_response)) {
        return $api_response;
    }
    
    $generation_time = microtime(true) - $start_time;
    
    // Download image and save to temp folder
    $temp_filename = sprintf(
        'variant_%d_%d.jpg',
        $variant_number,
        time()
    );
    $temp_filepath = $temp_dir . '/' . $temp_filename;
    
    $download_result = self::download_image(
        $api_response['url'],
        $temp_filepath
    );
    
    if (is_wp_error($download_result)) {
        return $download_result;
    }
    
    $file_size = filesize($temp_filepath);
    $cost = self::calculate_image_cost($image_model, $file_size);
    $temp_file_url = str_replace(
        ABSPATH,
        site_url() . '/',
        $temp_filepath
    );
    
    // Save variant record to DB
    global $wpdb;
    $variant_id = $wpdb->insert_id = $wpdb->insert(
        $wpdb->prefix . 'agentic_images_variants',
        [
            'agentic_image_id' => $image_record->id,
            'post_id' => $post_id,
            'agent_image_id' => $agent_image_id,
            'variant_number' => $variant_number,
            'temp_file_path' => $temp_filepath,
            'temp_file_url' => $temp_file_url,
            'file_size' => $file_size,
            'prompt_used' => $prompt,
            'image_model_used' => $image_model,
            'generation_time' => $generation_time,
            'cost' => $cost,
            'status' => 'temp'
        ]
    );
    
    return [
        'id' => $variant_id,
        'temp_file_url' => $temp_file_url,
        'variant_number' => $variant_number,
        'cost' => $cost,
        'generation_time' => $generation_time
    ];
}

/**
 * Call OpenRouter image generation API
 */
private static function call_image_api($image_model, $prompt) {
    $api_key = get_option('agentic_writer_openrouter_api_key');
    
    $response = wp_remote_post(
        'https://openrouter.ai/api/v1/images/generations',
        [
            'headers' => [
                'Authorization' => 'Bearer ' . $api_key,
                'Content-Type' => 'application/json',
            ],
            'body' => wp_json_encode([
                'model' => $image_model,
                'prompt' => $prompt,
                'n' => 1,
                'size' => '1024x576',  // Standard blog size
                'quality' => 'hd'
            ]),
            'timeout' => 60
        ]
    );
    
    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',
            'Image API error: ' . ($body['error']['message'] ?? 'Unknown error')
        );
    }
    
    return [
        'url' => $body['data'][0]['url'],
        'cost' => $body['usage']['cost'] ?? 0.03  // Fallback estimate
    ];
}

/**
 * Download image from URL to temp folder
 */
private static function download_image($url, $filepath) {
    $response = wp_remote_get($url);
    
    if (is_wp_error($response)) {
        return $response;
    }
    
    $body = wp_remote_retrieve_body($response);
    
    if (file_put_contents($filepath, $body) === false) {
        return new WP_Error('file_write_failed', 'Could not write temp image file');
    }
    
    return true;
}

/**
 * IMPORTANT: Regenerate does NOT delete old variants
 * It just adds new ones
 */
private static function get_temp_dir_for_image($post_id, $agent_image_id) {
    return WP_CONTENT_DIR . '/agentic-writer-temp/post_' . $post_id . '/' . $agent_image_id;
}

Flow 4: Variant Selection & Media Upload

Backend: Commit endpoint

<?php
/**
 * POST /wp-json/agentic-writer/v1/commit-image
 * 
 * User selected a variant. Upload to WordPress Media.
 */
public static function rest_commit_image( WP_REST_Request $request ) {
    $post_id = $request->get_param('post_id');
    $agent_image_id = $request->get_param('agent_image_id');
    $variant_id = $request->get_param('variant_id');
    $alt = $request->get_param('alt');
    
    // Validate
    if (!current_user_can('edit_post', $post_id)) {
        return new WP_Error('unauthorized', 'Not allowed', ['status' => 403]);
    }
    
    // Get variant
    $variant = self::get_variant($variant_id);
    if (!$variant || $variant->post_id != $post_id) {
        return new WP_Error('not_found', 'Variant not found', ['status' => 404]);
    }
    
    // Check temp file exists
    if (!file_exists($variant->temp_file_path)) {
        return new WP_Error('file_not_found', 'Temp image not found');
    }
    
    // Upload to WordPress Media using sideload
    $attachment_id = self::sideload_image_to_media(
        $variant->temp_file_path,
        $post_id,
        $alt
    );
    
    if (is_wp_error($attachment_id)) {
        return $attachment_id;
    }
    
    // Get attachment URL
    $attachment_url = wp_get_attachment_url($attachment_id);
    
    // Update main image record
    self::update_image_record_by_agent_image_id($post_id, $agent_image_id, [
        'attachment_id' => $attachment_id,
        'status' => 'committed'
    ]);
    
    // Mark this variant as selected
    self::update_variant($variant_id, [
        'is_selected' => 1,
        'selected_at' => current_time('mysql'),
        'status' => 'selected'
    ]);
    
    return new WP_REST_Response([
        'success' => true,
        'attachment_id' => $attachment_id,
        'attachment_url' => $attachment_url,
        'alt' => $alt,
        'message' => 'Image committed to WordPress Media.'
    ]);
}

/**
 * Sideload temp image to WordPress Media
 */
private static function sideload_image_to_media(
    $temp_filepath,
    $post_id,
    $alt_text
) {
    // Use WordPress sideload
    require_once(ABSPATH . 'wp-admin/includes/image.php');
    require_once(ABSPATH . 'wp-admin/includes/file.php');
    require_once(ABSPATH . 'wp-admin/includes/media.php');
    
    $filename = basename($temp_filepath);
    $file_array = [
        'name' => $filename,
        'tmp_name' => $temp_filepath
    ];
    
    $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', $alt_text);
    
    return $attachment_id;
}

Flow 5: Temp Image Management

Temp image lifecycle

1. Generated → stored in /agentic-writer-temp/post_X/img_Y/variant_N.jpg
   Status: 'temp'

2a. User selects variant → moved to WP Media Library
    Status: 'selected' (in wp_agentic_images_variants)
    Status: 'committed' (in wp_agentic_images)

2b. User regenerates (doesn't delete old variants)
    → New variants created in SAME folder
    → Old variants status remains 'temp'

3. Non-selected temps persist in DB until:
   - User manually deletes from admin page
   - Cron job runs (auto-cleanup > 7 days)
   
   Status transitions: 'temp' → 'discarded' → deleted from disk

Backend: List temp images endpoint

<?php
/**
 * GET /wp-json/agentic-writer/v1/temp-images?post_id=123
 * 
 * Show all temp images for a post
 */
public static function rest_list_temp_images( WP_REST_Request $request ) {
    $post_id = $request->get_param('post_id');
    
    if (!current_user_can('edit_post', $post_id)) {
        return new WP_Error('unauthorized', 'Not allowed', ['status' => 403]);
    }
    
    global $wpdb;
    
    $temps = $wpdb->get_results($wpdb->prepare(
        "SELECT v.*, i.agent_image_id, i.prompt_initial, i.alt_text_initial
         FROM {$wpdb->prefix}agentic_images_variants v
         JOIN {$wpdb->prefix}agentic_images i ON v.agentic_image_id = i.id
         WHERE v.post_id = %d
         AND v.status IN ('temp', 'discarded')
         ORDER BY v.created_at DESC",
        $post_id
    ));
    
    return new WP_REST_Response([
        'total' => count($temps),
        'images' => $temps
    ]);
}

/**
 * DELETE /wp-json/agentic-writer/v1/temp-images/<variant_id>
 * 
 * Manually delete a temp image
 */
public static function rest_delete_temp_image( WP_REST_Request $request ) {
    $variant_id = $request->get_param('variant_id');
    
    $variant = self::get_variant($variant_id);
    if (!$variant) {
        return new WP_Error('not_found', 'Variant not found');
    }
    
    if (!current_user_can('edit_post', $variant->post_id)) {
        return new WP_Error('unauthorized', 'Not allowed');
    }
    
    // Delete file
    if (file_exists($variant->temp_file_path)) {
        unlink($variant->temp_file_path);
    }
    
    // Mark as deleted in DB
    self::update_variant($variant_id, [
        'status' => 'discarded',
        'deleted_at' => current_time('mysql')
    ]);
    
    return new WP_REST_Response([
        'success' => true,
        'message' => 'Temp image deleted.'
    ]);
}

/**
 * Cron job: Auto-cleanup old temps (> 7 days)
 */
public static function cleanup_old_temp_images() {
    global $wpdb;
    
    $cutoff_date = date('Y-m-d H:i:s', strtotime('-7 days'));
    
    // Get old temp images
    $old_temps = $wpdb->get_results($wpdb->prepare(
        "SELECT * FROM {$wpdb->prefix}agentic_images_variants
         WHERE status = 'temp'
         AND created_at < %s",
        $cutoff_date
    ));
    
    foreach ($old_temps as $temp) {
        // Delete file
        if (file_exists($temp->temp_file_path)) {
            unlink($temp->temp_file_path);
        }
        
        // Mark as auto-deleted
        $wpdb->update(
            $wpdb->prefix . 'agentic_images_variants',
            [
                'status' => 'auto_deleted',
                'deleted_at' => current_time('mysql')
            ],
            [ 'id' => $temp->id ]
        );
    }
}

// Hook into WordPress cron
add_action('agentic_writer_cleanup_temps', [__CLASS__, 'cleanup_old_temp_images']);

// Schedule once
if (!wp_next_scheduled('agentic_writer_cleanup_temps')) {
    wp_schedule_event(
        time(),
        'daily',
        'agentic_writer_cleanup_temps'
    );
}

Admin Page: Image Library

New tab in plugin settings

Plugin Settings → Generated Images (new tab)

┌────────────────────────────────────────────────────────┐
│ Generated Images Library                                │
├────────────────────────────────────────────────────────┤
│                                                         │
│ Filter by: [Post ▼] [Status ▼] [Date ▼]               │
│                                                         │
│ [Select All] [Delete Selected] [Auto-cleanup Old]     │
│                                                         │
│ ─────────────────────────────────────────────────────  │
│                                                         │
│ [Thumb] Post: "Article Title"                          │
│         Agent Image ID: img_hero_1                      │
│         Status: temp                                    │
│         Created: Jan 27, 2026 2:30 PM                   │
│         Cost: $0.03                                     │
│         [ ] [View] [Delete]                             │
│                                                         │
│ [Thumb] Post: "Article Title"                          │
│         Agent Image ID: img_diag_1                      │
│         Status: selected (used in Media Library)        │
│         Created: Jan 27, 2026 2:25 PM                   │
│         Cost: $0.03                                     │
│         [ ] [View]                                      │
│                                                         │
│ ─────────────────────────────────────────────────────  │
│                                                         │
│ Total cost (all time): $123.45                         │
│ Total temps (>7 days): 12 images (ready for cleanup)   │
│                                                         │
│ [Auto-cleanup Now]                                     │
│                                                         │
└────────────────────────────────────────────────────────┘

Implementation

<?php
/**
 * Admin page: Generated Images library
 */
public static function render_admin_generated_images() {
    global $wpdb;
    
    $post_id = isset($_GET['post_id']) ? intval($_GET['post_id']) : null;
    $status = isset($_GET['status']) ? sanitize_text_field($_GET['status']) : null;
    
    $query = "SELECT v.*, i.agent_image_id, i.section_title, p.post_title
              FROM {$wpdb->prefix}agentic_images_variants v
              JOIN {$wpdb->prefix}agentic_images i ON v.agentic_image_id = i.id
              JOIN {$wpdb->posts} p ON v.post_id = p.ID
              WHERE 1=1";
    
    if ($post_id) {
        $query .= $wpdb->prepare(" AND v.post_id = %d", $post_id);
    }
    
    if ($status) {
        $query .= $wpdb->prepare(" AND v.status = %s", $status);
    }
    
    $query .= " ORDER BY v.created_at DESC";
    
    $images = $wpdb->get_results($query);
    
    $total_cost = $wpdb->get_var(
        "SELECT SUM(cost) FROM {$wpdb->prefix}agentic_images_variants"
    );
    
    ?>
    <div class="wrap">
        <h1>Generated Images Library</h1>
        
        <div class="filters">
            <input type="hidden" name="page" value="agentic-writer-settings">
            <input type="hidden" name="tab" value="generated_images">
            
            <select name="post_id">
                <option value="">All Posts</option>
                <?php foreach (get_posts(['numberposts' => -1]) as $post): ?>
                    <option value="<?php echo $post->ID; ?>"<?php selected($post_id, $post->ID); ?>>
                        <?php echo esc_html($post->post_title); ?>
                    </option>
                <?php endforeach; ?>
            </select>
            
            <select name="status">
                <option value="">All Statuses</option>
                <option value="temp"<?php selected($status, 'temp'); ?>>Temp</option>
                <option value="selected"<?php selected($status, 'selected'); ?>>Selected</option>
                <option value="discarded"<?php selected($status, 'discarded'); ?>>Discarded</option>
            </select>
            
            <button type="submit" class="button">Filter</button>
        </div>
        
        <form method="post">
            <?php wp_nonce_field('agentic_images_bulk_action'); ?>
            
            <table class="wp-list-table widefat">
                <thead>
                    <tr>
                        <td class="check-column"><input type="checkbox" /></td>
                        <th>Image</th>
                        <th>Post</th>
                        <th>Agent Image ID</th>
                        <th>Status</th>
                        <th>Created</th>
                        <th>Cost</th>
                        <th>Actions</th>
                    </tr>
                </thead>
                <tbody>
                    <?php foreach ($images as $image): ?>
                        <tr>
                            <td><input type="checkbox" name="variants[]" value="<?php echo $image->id; ?>" /></td>
                            <td>
                                <a href="<?php echo $image->temp_file_url; ?>" target="_blank">
                                    <img src="<?php echo $image->temp_file_url; ?>" 
                                         style="max-width: 100px; max-height: 100px;">
                                </a>
                            </td>
                            <td><?php echo esc_html($image->post_title); ?></td>
                            <td><?php echo esc_html($image->agent_image_id); ?></td>
                            <td><span class="status-badge status-<?php echo $image->status; ?>">
                                <?php echo ucfirst($image->status); ?>
                            </span></td>
                            <td><?php echo date_i18n('M d, Y g:i A', strtotime($image->created_at)); ?></td>
                            <td>$<?php echo number_format($image->cost, 3); ?></td>
                            <td>
                                <a href="<?php echo $image->temp_file_url; ?>" target="_blank">View</a>
                                <?php if ($image->status === 'temp'): ?>
                                    | <a href="#" class="delete-variant" data-variant-id="<?php echo $image->id; ?>">Delete</a>
                                <?php endif; ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
            
            <div class="bulk-actions">
                <select name="action">
                    <option value="">Bulk Actions</option>
                    <option value="delete">Delete Selected</option>
                </select>
                <button type="submit" class="button">Apply</button>
            </div>
        </form>
        
        <div class="summary-box">
            <p><strong>Total Cost (all time):</strong> $<?php echo number_format($total_cost, 2); ?></p>
            <p><strong>Temp Images Ready for Cleanup (>7 days):</strong> 
                <?php echo $wpdb->get_var(
                    "SELECT COUNT(*) FROM {$wpdb->prefix}agentic_images_variants
                     WHERE status = 'temp' AND created_at < DATE_SUB(NOW(), INTERVAL 7 DAY)"
                ); ?>
            </p>
            <button type="button" class="button button-primary" id="cleanup-old">Auto-cleanup Old Temps</button>
        </div>
    </div>
    <?php
}

REST API Endpoints

Summary

Endpoint Method Purpose
/agentic-writer/v1/generate-image POST Start generation (creates variants)
/agentic-writer/v1/commit-image POST Select variant + upload to Media
/agentic-writer/v1/temp-images GET List all temp images for post
/agentic-writer/v1/temp-images/<id> DELETE Delete a specific temp image

Endpoint definitions

<?php
add_action('rest_api_init', function() {
    register_rest_route('agentic-writer/v1', '/generate-image', [
        'methods' => 'POST',
        'callback' => [Agentic_Writer_Images::class, 'rest_generate_image'],
        'permission_callback' => '__return_true'  // Checked inside function
    ]);
    
    register_rest_route('agentic-writer/v1', '/commit-image', [
        'methods' => 'POST',
        'callback' => [Agentic_Writer_Images::class, 'rest_commit_image'],
        'permission_callback' => '__return_true'
    ]);
    
    register_rest_route('agentic-writer/v1', '/temp-images', [
        'methods' => 'GET',
        'callback' => [Agentic_Writer_Images::class, 'rest_list_temp_images'],
        'permission_callback' => '__return_true'
    ]);
    
    register_rest_route('agentic-writer/v1', '/temp-images/(?P<variant_id>\d+)', [
        'methods' => 'DELETE',
        'callback' => [Agentic_Writer_Images::class, 'rest_delete_temp_image'],
        'permission_callback' => '__return_true'
    ]);
});

Implementation Checklist

Phase 1: Database & Data Model

  • Create wp_agentic_images table
  • Create wp_agentic_images_variants table
  • Add migration script

Phase 2: Agent Response Handling

  • Update agent execution to return images array
  • Save recommendations to wp_agentic_images
  • Insert core/image blocks with data-agent-image-id

Phase 3: Gutenberg Integration

  • Register plugin for block toolbar button
  • Create modal component (Edit → Generate → Select flow)
  • Implement updateBlockAttributes to commit selection

Phase 4: Backend Image Generation

  • Implement POST /generate-image endpoint
  • Handle variant generation (1-3 images per call)
  • Save variants to temp folder + DB
  • Note: Regenerate creates NEW variants, doesn't delete old

Phase 5: Media Upload

  • Implement POST /commit-image endpoint
  • Use media_handle_sideload() to upload
  • Set attachment alt from user-edited value
  • Update Gutenberg block attributes

Phase 6: Temp Management

  • Implement GET /temp-images endpoint
  • Implement DELETE /temp-images/<id> endpoint
  • Set up cron job for auto-cleanup (>7 days)
  • Create cleanup directory function

Phase 7: Admin Page

  • Add "Generated Images" tab in plugin settings
  • List all temps/selected images with filters
  • Bulk delete action
  • Manual cleanup button
  • Cost summary display

Phase 8: Testing & UX Polish

  • Test full generation → selection → upload flow
  • Test regenerate (old variants persist)
  • Test cleanup (old temps auto-delete at 7+ days)
  • Test admin page filtering + deletion
  • Verify cost tracking accuracy

Key Notes

Regenerate Behavior

User clicks "Regenerate" in modal
→ Calls POST /generate-image AGAIN with SAME prompt
→ Backend generates 3 NEW variants
→ NEW variants saved to DB (variant_number = 2, 3, 4, etc)
→ OLD variants (variant_number = 1) remain in DB with status='temp'
→ Temp folder now has: variant_1.jpg, variant_2.jpg, variant_3.jpg, variant_4.jpg, ...
→ Modal shows ALL variants (user can pick from any)
→ Selected variant gets attached to post
→ Non-selected remain as 'temp' until manual delete or cron cleanup

Cost Transparency

Each variant_number records:
- cost: Individual image cost
- prompt_used: Exact prompt for that generation
- generation_time: How long it took

Main image record:
- cost_actual: SUM of all variants generated
- image_model: Which model was used
- prompt_edited: User's final (possibly edited) prompt
- alt_text_edited: User's final alt

File Structure (Example)

/wp-content/agentic-writer-temp/
├── post_123/
│   ├── img_hero_1/
│   │   ├── variant_1_1706364000.jpg      (first generation)
│   │   ├── variant_2_1706364015.jpg      (first generation, variant 2)
│   │   ├── variant_3_1706364030.jpg      (first generation, variant 3)
│   │   ├── variant_1_1706364060.jpg      (second generation, regenerate)
│   │   ├── variant_2_1706364075.jpg      (second generation, regenerate)
│   │   └── variant_3_1706364090.jpg      (second generation, regenerate)
│   └── img_diag_1/
│       └── variant_1_1706364120.jpg      (only 1 generated)
└── post_456/
    └── img_hero_1/
        └── variant_1_1706364150.jpg

Document version: 1.0
Date: January 27, 2026
Status: Ready for Phase 1 implementation