/** * 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, createRoot } = wp.element; window.wpAgenticWriter = window.wpAgenticWriter || {}; /** * Image Review Modal * Shows after article generation with image recommendations */ window.wpAgenticWriter.ImageReviewModal = function({ postId, initialImageId, initialBlockId, onClose, onComplete }) { const [step, setStep] = useState('loading'); const [images, setImages] = useState([]); const [generatedImages, setGeneratedImages] = useState([]); const [selectedImages, setSelectedImages] = useState([]); const [variantCounts, setVariantCounts] = useState({}); const [isGenerating, setIsGenerating] = useState(false); const [committingVariantId, setCommittingVariantId] = useState(null); const [extraVariantCounts, setExtraVariantCounts] = useState({}); const [generatingMoreFor, setGeneratingMoreFor] = useState(null); const [error, setError] = useState(null); useEffect(() => { loadImageRecommendations(); }, []); const scopedImages = (() => { if (initialImageId) { const direct = images.filter((img) => String(img.agent_image_id || '').trim() === String(initialImageId).trim()); if (direct.length > 0) { return direct; } } if (!initialBlockId) { return images; } const allBlocks = wp.data.select('core/block-editor').getBlocks() || []; const flatImageBlocks = []; const walk = (blocks) => { blocks.forEach((block) => { if (block?.name === 'core/image') { flatImageBlocks.push(block); } if (Array.isArray(block?.innerBlocks) && block.innerBlocks.length > 0) { walk(block.innerBlocks); } }); }; walk(allBlocks); const slotIndex = flatImageBlocks.findIndex((block) => block?.clientId === initialBlockId); if (slotIndex < 0) { return images; } const placement = `slot_${slotIndex + 1}`; const placementScoped = images.filter((img) => String(img.placement || '').toLowerCase() === placement); return placementScoped.length > 0 ? placementScoped : images; })(); useEffect(() => { if ((!initialImageId && !initialBlockId) || scopedImages.length === 0) { return; } const scopedIds = scopedImages.map((img) => img.agent_image_id).filter(Boolean); if (scopedIds.length === 0) { return; } const normalize = (items) => [...items].sort().join('|'); setSelectedImages((prev) => { if (normalize(prev) === normalize(scopedIds)) { return prev; } return scopedIds; }); }, [initialImageId, initialBlockId, scopedImages.length]); 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(await extractApiErrorMessage(response, '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); if (initialImageId) { const preferred = imgs.filter((img) => String(img.agent_image_id || '').trim() === String(initialImageId).trim()); if (preferred.length > 0) { setSelectedImages(preferred.map((img) => img.agent_image_id)); } } 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 calculateTotalVariants = () => { let total = 0; selectedImages.forEach((imageId) => { total += variantCounts[imageId] || 2; }); return total; }; const handleGenerateSelected = async () => { if (selectedImages.length === 0) { alert('Please select at least one image to generate'); return; } setIsGenerating(true); setStep('generating'); try { const generated = []; for (const imageId of selectedImages) { const image = images.find(img => img.agent_image_id === imageId); if (!image) { throw new Error(`Image recommendation was not found for ${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] || 2, }), } ); if (!response.ok) { const detailedError = await extractApiErrorMessage( response, `Failed to generate image: ${imageId}` ); throw new Error(detailedError); } const result = await response.json(); const imageWithVariants = { ...image, variants: result.variants || [] }; generated.push(imageWithVariants); setImages(prev => prev.map(img => img.agent_image_id === imageId ? imageWithVariants : img )); } setGeneratedImages(generated); setStep('selecting'); } catch (err) { setError(err.message); setStep('review'); } finally { setIsGenerating(false); } }; const handleSelectVariant = async (imageId, variantId) => { const image = generatedImages.find(img => img.agent_image_id === imageId) || images.find(img => img.agent_image_id === imageId); try { setCommittingVariantId(variantId); 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(await extractApiErrorMessage(response, 'Failed to commit image')); } const result = await response.json(); const didUpdateBlock = updateGutenbergBlock(imageId, result); setImages(prev => prev.map(img => img.agent_image_id === imageId ? { ...img, status: 'committed', attachment_id: result.attachment_id } : img )); setGeneratedImages(prev => prev.map(img => img.agent_image_id === imageId ? { ...img, status: 'committed', attachment_id: result.attachment_id } : img )); if (!didUpdateBlock) { throw new Error('Image committed to Media Library, but the matching image block could not be found.'); } onComplete(); } catch (err) { alert('Failed to commit image: ' + err.message); setCommittingVariantId(null); } }; const handleGenerateMore = async (imageId) => { const image = generatedImages.find(img => img.agent_image_id === imageId) || images.find(img => img.agent_image_id === imageId); if (!image) { alert('Image recommendation not found.'); return; } const moreCount = extraVariantCounts[imageId] || 1; setGeneratingMoreFor(imageId); try { 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: moreCount, }), } ); if (!response.ok) { throw new Error(await extractApiErrorMessage(response, `Failed to generate more variants for ${imageId}`)); } const result = await response.json(); const newVariants = Array.isArray(result?.variants) ? result.variants : []; if (newVariants.length === 0) { throw new Error('No additional variants returned.'); } setGeneratedImages(prev => prev.map(img => img.agent_image_id === imageId ? { ...img, variants: [...(img.variants || []), ...newVariants] } : img )); setImages(prev => prev.map(img => img.agent_image_id === imageId ? { ...img, variants: [...(img.variants || []), ...newVariants] } : img )); } catch (err) { alert('Failed to generate more variants: ' + err.message); } finally { setGeneratingMoreFor(null); } }; const extractApiErrorMessage = async (response, fallback) => { try { const data = await response.clone().json(); const message = data?.message || data?.error || ''; const details = data?.details || data?.reason || data?.provider_error || ''; const trace = data?.data?.trace || data?.trace || null; const traceText = formatTraceForDisplay(trace); const combined = [message, details, traceText].filter(Boolean).join('\n\n').trim(); if (combined) { return combined; } } catch (jsonError) { // Continue to text fallback. } try { const text = (await response.text()).trim(); if (text) { return text; } } catch (textError) { // Ignore read error. } return fallback; }; const formatTraceForDisplay = (trace) => { if (!trace || typeof trace !== 'object') { return ''; } const openRouterMessage = trace.openrouter_response?.error?.message || trace.openrouter_response?.message || ''; const lines = [ 'Trace:', `selected_model: ${trace.selected_model || ''}`, `settings_image_model: ${trace.settings_image_model || ''}`, `image_task_provider: ${trace.image_task_provider || ''}`, `custom_model_match: ${trace.custom_model_match ? 'yes' : 'no'}`, `custom_model_ids: ${Array.isArray(trace.custom_model_ids) ? trace.custom_model_ids.join(', ') : ''}`, `model_cache_loaded: ${trace.model_cache_loaded ? 'yes' : 'no'}`, `model_cache_has_model: ${trace.model_cache_has_model ? 'yes' : 'no'}`, `refreshed_has_model: ${trace.refreshed_has_model === null ? 'n/a' : (trace.refreshed_has_model ? 'yes' : 'no')}`, `endpoint: ${trace.endpoint || ''}`, `request_model: ${trace.request_model || ''}`, `request_size: ${trace.request_size || ''}`, `request_quality: ${trace.request_quality || ''}`, `request_n: ${trace.request_n || ''}`, `openrouter_http: ${trace.openrouter_http || ''}`, ]; if (openRouterMessage) { lines.push(`openrouter_message: ${openRouterMessage}`); } return lines.filter((line) => !line.endsWith(': ')).join('\n'); }; const updateGutenbergBlock = (agentImageId, attachmentData) => { const editor = wp.data.select('core/block-editor'); const dispatcher = wp.data.dispatch('core/block-editor'); const blocks = editor.getBlocks(); const updateBlock = (clientId) => { if (!clientId) { return false; } dispatcher.updateBlockAttributes( clientId, { id: attachmentData.attachment_id, url: attachmentData.attachment_url, alt: attachmentData.alt, caption: '', 'data-agent-image-id': agentImageId, } ); return true; }; const findAndUpdateBlock = (blocks) => { for (const block of blocks) { if (block.name === 'core/image' && block.attributes['data-agent-image-id'] === agentImageId) { return updateBlock(block.clientId); } if (block.innerBlocks && block.innerBlocks.length > 0) { if (findAndUpdateBlock(block.innerBlocks)) { return true; } } } return false; }; if (findAndUpdateBlock(blocks)) { return true; } const initialBlock = initialBlockId ? editor.getBlock(initialBlockId) : null; if (initialBlock?.name === 'core/image') { return updateBlock(initialBlockId); } const selectedBlock = editor.getSelectedBlock(); if (selectedBlock?.name === 'core/image') { return updateBlock(selectedBlock.clientId); } return false; }; 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 (${scopedImages.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), scopedImages.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') ), scopedImages.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', minWidth: '150px' } }, 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 ${calculateTotalVariants()} 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') { const selectableImages = generatedImages.filter(img => img.variants && img.variants.length > 0); return wp.element.createElement(Modal, { title: 'Select Image Variants', onRequestClose: onClose, style: { maxWidth: '900px' }, }, wp.element.createElement('div', { className: 'wpaw-variant-selection' }, selectableImages.length === 0 && wp.element.createElement('div', { style: { padding: '24px', color: '#666' } }, 'No generated variants were returned. Please try generating again.'), selectableImages .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: 'flex', alignItems: 'center', gap: '8px', marginBottom: '10px', } }, wp.element.createElement('label', { style: { fontSize: '12px', color: '#555' } }, 'Generate More'), wp.element.createElement('select', { value: extraVariantCounts[image.agent_image_id] || 1, onChange: (e) => setExtraVariantCounts(prev => ({ ...prev, [image.agent_image_id]: parseInt(e.target.value, 10) })), disabled: generatingMoreFor !== null || committingVariantId !== null, style: { padding: '4px 6px', borderRadius: '4px', border: '1px solid #ddd', minWidth: '60px' } }, wp.element.createElement('option', { value: '1' }, '+1'), wp.element.createElement('option', { value: '2' }, '+2'), wp.element.createElement('option', { value: '3' }, '+3') ), wp.element.createElement(Button, { variant: 'secondary', onClick: () => handleGenerateMore(image.agent_image_id), disabled: generatingMoreFor !== null || committingVariantId !== null, }, generatingMoreFor === image.agent_image_id ? 'Generating...' : 'Generate More') ), 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)} • ${Math.round(Number(variant.generation_time) || 0)}s` ), wp.element.createElement(Button, { variant: 'primary', onClick: () => handleSelectVariant(image.agent_image_id, variant.id), disabled: committingVariantId !== null, style: { width: '100%' }, }, committingVariantId === variant.id ? 'Selecting...' : '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; let modalRoot = null; const mountModal = (element) => { if (typeof createRoot === 'function') { if (!modalRoot) { modalRoot = createRoot(modalContainer); } modalRoot.render(element); return; } render(element, modalContainer); }; const unmountModal = () => { if (modalRoot && typeof modalRoot.unmount === 'function') { modalRoot.unmount(); modalRoot = null; } else if (modalContainer) { render(null, modalContainer); } 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 = wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, { postId: postId, onClose: () => { unmountModal(); }, onComplete: () => { unmountModal(); }, }); mountModal(currentModalInstance); }); /** * 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 = wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, { postId: postId, initialImageId: agentImageId, initialBlockId: blockId, onClose: () => { unmountModal(); }, onComplete: () => { unmountModal(); }, }); mountModal(currentModalInstance); }); })();