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
This commit is contained in:
519
assets/js/image-modal.js
Normal file
519
assets/js/image-modal.js
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* Image Generation Modal Component
|
||||
*
|
||||
* Handles image review, generation, variant selection, and commitment.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
(function() {
|
||||
const { Modal, Button, Spinner, TextControl, TextareaControl } = wp.components;
|
||||
const { useState, useEffect, render } = wp.element;
|
||||
|
||||
window.wpAgenticWriter = window.wpAgenticWriter || {};
|
||||
|
||||
/**
|
||||
* Image Review Modal
|
||||
* Shows after article generation with image recommendations
|
||||
*/
|
||||
window.wpAgenticWriter.ImageReviewModal = function({ postId, initialImageId, onClose, onComplete }) {
|
||||
const [step, setStep] = useState('loading');
|
||||
const [images, setImages] = useState([]);
|
||||
const [selectedImages, setSelectedImages] = useState([]);
|
||||
const [variantCounts, setVariantCounts] = useState({});
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
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);
|
||||
|
||||
const initialCounts = {};
|
||||
imgs.forEach(img => {
|
||||
initialCounts[img.agent_image_id] = 2;
|
||||
});
|
||||
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';
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
updateGutenbergBlock(imageId, result);
|
||||
|
||||
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) => {
|
||||
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,
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (block.innerBlocks && block.innerBlocks.length > 0) {
|
||||
if (findAndUpdateBlock(block.innerBlocks)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
findAndUpdateBlock(blocks);
|
||||
};
|
||||
|
||||
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.length === 0 && wp.element.createElement('div', {
|
||||
style: {
|
||||
padding: '40px 20px',
|
||||
textAlign: 'center',
|
||||
color: '#666',
|
||||
}
|
||||
},
|
||||
wp.element.createElement('p', { style: { fontSize: '16px', marginBottom: '10px' } },
|
||||
'📷 No image recommendations available'
|
||||
),
|
||||
wp.element.createElement('p', { style: { fontSize: '14px', marginBottom: '20px' } },
|
||||
'Images are generated during article writing. You can add images manually or generate them later.'
|
||||
),
|
||||
wp.element.createElement(Button, {
|
||||
variant: 'primary',
|
||||
onClick: onClose,
|
||||
}, 'Continue Without Images')
|
||||
),
|
||||
|
||||
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')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize modal container and event listeners
|
||||
let modalContainer = null;
|
||||
let currentModalInstance = null;
|
||||
|
||||
/**
|
||||
* Open image modal for review after article generation
|
||||
*/
|
||||
window.addEventListener('wpaw:open-image-review-modal', (event) => {
|
||||
const { postId, imageCount } = event.detail;
|
||||
|
||||
if (!modalContainer) {
|
||||
modalContainer = document.createElement('div');
|
||||
modalContainer.id = 'wpaw-image-modal-root';
|
||||
document.body.appendChild(modalContainer);
|
||||
}
|
||||
|
||||
currentModalInstance = render(
|
||||
wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, {
|
||||
postId: postId,
|
||||
onClose: () => {
|
||||
if (modalContainer) {
|
||||
render(null, modalContainer);
|
||||
currentModalInstance = null;
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
if (modalContainer) {
|
||||
render(null, modalContainer);
|
||||
currentModalInstance = null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
modalContainer
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Open image modal for single image from toolbar
|
||||
*/
|
||||
window.addEventListener('wpaw:open-image-modal', (event) => {
|
||||
const { agentImageId, blockId } = event.detail;
|
||||
const postId = wp.data.select('core/editor').getCurrentPostId();
|
||||
|
||||
if (!modalContainer) {
|
||||
modalContainer = document.createElement('div');
|
||||
modalContainer.id = 'wpaw-image-modal-root';
|
||||
document.body.appendChild(modalContainer);
|
||||
}
|
||||
|
||||
currentModalInstance = render(
|
||||
wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, {
|
||||
postId: postId,
|
||||
initialImageId: agentImageId,
|
||||
onClose: () => {
|
||||
if (modalContainer) {
|
||||
render(null, modalContainer);
|
||||
currentModalInstance = null;
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
if (modalContainer) {
|
||||
render(null, modalContainer);
|
||||
currentModalInstance = null;
|
||||
}
|
||||
},
|
||||
}),
|
||||
modalContainer
|
||||
);
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user