Files
wp-agentic-writer/docs/features/image-gen-flow.md

1346 lines
46 KiB
Markdown

# 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](#overview--architecture)
2. [Data Model](#data-model)
3. [Flow 1: Article Generation & Image Recommendations](#flow-1-article-generation--image-recommendations)
4. [Flow 2: Image Block Toolbar & Modal](#flow-2-image-block-toolbar--modal)
5. [Flow 3: Image Generation (Variants)](#flow-3-image-generation-variants)
6. [Flow 4: Variant Selection & Media Upload](#flow-4-variant-selection--media-upload)
7. [Flow 5: Temp Image Management](#flow-5-temp-image-management)
8. [Admin Page: Image Library](#admin-page-image-library)
9. [REST API Endpoints](#rest-api-endpoints)
10. [Implementation Checklist](#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.
```sql
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.
```sql
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:
```json
{
"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
<?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:
```jsx
// 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
```jsx
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
<?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
<?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
<?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
<?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
<?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