diff --git a/assets/js/block-image-generate.js b/assets/js/block-image-generate.js index 9f7c4eb..aa04c1b 100644 --- a/assets/js/block-image-generate.js +++ b/assets/js/block-image-generate.js @@ -60,16 +60,13 @@ }; const agentImageId = getAgentImageId(); - if (!agentImageId) { - return wp.element.createElement(BlockEdit, props); - } const openImageModal = () => { // Dispatch custom event to open image generation modal window.dispatchEvent( new CustomEvent('wpaw:open-image-modal', { detail: { - agentImageId: agentImageId, + agentImageId: agentImageId || null, blockId: clientId, }, }) diff --git a/assets/js/image-modal.js b/assets/js/image-modal.js index 0e7049a..74a92ca 100644 --- a/assets/js/image-modal.js +++ b/assets/js/image-modal.js @@ -8,7 +8,7 @@ (function() { const { Modal, Button, Spinner, TextControl, TextareaControl } = wp.components; - const { useState, useEffect, render } = wp.element; + const { useState, useEffect, render, createRoot } = wp.element; window.wpAgenticWriter = window.wpAgenticWriter || {}; @@ -16,17 +16,74 @@ * Image Review Modal * Shows after article generation with image recommendations */ - window.wpAgenticWriter.ImageReviewModal = function({ postId, initialImageId, onClose, onComplete }) { + 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 { @@ -40,7 +97,7 @@ ); if (!response.ok) { - throw new Error('Failed to load image recommendations'); + throw new Error(await extractApiErrorMessage(response, 'Failed to load image recommendations')); } const data = await response.json(); @@ -52,6 +109,13 @@ 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) { @@ -103,6 +167,14 @@ 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) { @@ -114,8 +186,12 @@ 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`, @@ -130,24 +206,31 @@ 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, + variant_count: variantCounts[imageId] || 2, }), } ); if (!response.ok) { - throw new Error(`Failed to generate image: ${imageId}`); + 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 - ? { ...img, variants: result.variants } + ? imageWithVariants : img )); } + setGeneratedImages(generated); setStep('selecting'); } catch (err) { setError(err.message); @@ -158,9 +241,11 @@ }; const handleSelectVariant = async (imageId, variantId) => { - const image = images.find(img => img.agent_image_id === imageId); + 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`, { @@ -179,41 +264,175 @@ ); if (!response.ok) { - throw new Error('Failed to commit image'); + throw new Error(await extractApiErrorMessage(response, 'Failed to commit image')); } const result = await response.json(); - updateGutenbergBlock(imageId, result); + 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 blocks = wp.data.select('core/block-editor').getBlocks(); + 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) { - - 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; + return updateBlock(block.clientId); } if (block.innerBlocks && block.innerBlocks.length > 0) { @@ -225,7 +444,21 @@ return false; }; - findAndUpdateBlock(blocks); + 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') { @@ -241,7 +474,7 @@ if (step === 'review') { return wp.element.createElement(Modal, { - title: `Image Recommendations (${images.length})`, + title: `Image Recommendations (${scopedImages.length})`, onRequestClose: onClose, style: { maxWidth: '800px' }, }, @@ -251,7 +484,7 @@ style: { marginBottom: '20px' } }, error), - images.length === 0 && wp.element.createElement('div', { + scopedImages.length === 0 && wp.element.createElement('div', { style: { padding: '40px 20px', textAlign: 'center', @@ -270,7 +503,7 @@ }, 'Continue Without Images') ), - images.map(image => + scopedImages.map(image => wp.element.createElement('div', { key: image.agent_image_id, className: 'wpaw-image-card', @@ -311,6 +544,7 @@ padding: '5px', borderRadius: '3px', border: '1px solid #ddd', + minWidth: '150px' } }, wp.element.createElement('option', { value: '1' }, '1 variant'), @@ -356,7 +590,7 @@ variant: 'primary', onClick: handleGenerateSelected, disabled: selectedImages.length === 0 || isGenerating, - }, `Generate ${selectedImages.length} Image(s) (~$${calculateTotalCost()})`) + }, `Generate ${calculateTotalVariants()} Image(s) (~$${calculateTotalCost()})`) ) ) ); @@ -380,20 +614,56 @@ } 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' }, - images - .filter(img => img.variants && img.variants.length > 0) + 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: { @@ -419,14 +689,15 @@ 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` + `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%' }, - }, 'Select') + }, committingVariantId === variant.id ? 'Selecting...' : 'Select') ) ) ) @@ -450,6 +721,28 @@ // 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 @@ -463,24 +756,16 @@ document.body.appendChild(modalContainer); } - currentModalInstance = render( - wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, { + currentModalInstance = wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, { postId: postId, onClose: () => { - if (modalContainer) { - render(null, modalContainer); - currentModalInstance = null; - } + unmountModal(); }, onComplete: () => { - if (modalContainer) { - render(null, modalContainer); - currentModalInstance = null; - } + unmountModal(); }, - }), - modalContainer - ); + }); + mountModal(currentModalInstance); }); /** @@ -496,24 +781,17 @@ document.body.appendChild(modalContainer); } - currentModalInstance = render( - wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, { + currentModalInstance = wp.element.createElement(window.wpAgenticWriter.ImageReviewModal, { postId: postId, initialImageId: agentImageId, + initialBlockId: blockId, onClose: () => { - if (modalContainer) { - render(null, modalContainer); - currentModalInstance = null; - } + unmountModal(); }, onComplete: () => { - if (modalContainer) { - render(null, modalContainer); - currentModalInstance = null; - } + unmountModal(); }, - }), - modalContainer - ); + }); + mountModal(currentModalInstance); }); })(); diff --git a/assets/js/sidebar.js b/assets/js/sidebar.js index 49c330d..3627d47 100644 --- a/assets/js/sidebar.js +++ b/assets/js/sidebar.js @@ -6,11 +6,26 @@ (function (wp) { const { registerPlugin } = wp.plugins; + const { PluginSidebarMoreMenuItem } = wp.editPost; const { PluginSidebar } = wp.editPost; const { Panel, TextareaControl, TextControl, CheckboxControl, Button } = wp.components; const { dispatch, select } = wp.data; const { RawHTML } = wp.element; + // Debug logger - only logs when SCRIPT_DEBUG is enabled + const isDebug = typeof wpAgenticWriter !== 'undefined' && wpAgenticWriter.debug; + const wpawLog = { + log: (...args) => { if (isDebug) console.log('[WPAW]', ...args); }, + error: (...args) => console.error('[WPAW]', ...args), // Always log errors + info: (...args) => { if (isDebug) console.info('[WPAW]', ...args); }, + warn: (...args) => { if (isDebug) console.warn('[WPAW]', ...args); }, + }; + const pluginIcon = wp.element.createElement('img', { + src: wpAgenticWriter.pluginUrl + '/assets/img/icon.svg', + alt: 'WP Agentic Writer', + style: { width: '20px', height: '20px' } + }); + // Sidebar Component. const AgenticWriterSidebar = ({ postId }) => { // Get settings from wpAgenticWriter global. @@ -23,6 +38,9 @@ const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [isLoading, setIsLoading] = React.useState(false); + const [currentSessionId, setCurrentSessionId] = React.useState(''); + const [availableSessions, setAvailableSessions] = React.useState([]); + const [isSessionActionLoading, setIsSessionActionLoading] = React.useState(false); const [agentMode, setAgentMode] = React.useState(() => { try { return window.localStorage.getItem('wpawAgentMode') || 'chat'; @@ -59,6 +77,31 @@ // Cost state const [cost, setCost] = React.useState({ session: 0, today: 0, monthlyUsed: 0 }); const [monthlyBudget, setMonthlyBudget] = React.useState(settings.monthly_budget || 600); + + // Provider info state for transparency display + const [providerInfo, setProviderInfo] = React.useState(null); + + // Helper to extract and apply provider metadata from any AI response + const applyProviderMetadata = (data) => { + if (!data) return; + if (data.session_id) { + setCurrentSessionId(data.session_id); + } + + // Support both nested provider_metadata and top-level provider fields + const meta = data.provider_metadata || data; + const provider = meta.provider || meta.selected_provider || meta.provider; + + if (provider) { + setProviderInfo({ + provider: provider, + model: meta.model, + fallbackUsed: meta.fallback_used || meta.fallbackUsed, + warnings: meta.warnings || [] + }); + } + }; + const [isEditorLocked, setIsEditorLocked] = React.useState(false); // SEO audit state @@ -356,7 +399,7 @@ timestamp: new Date(), }]); } catch (error) { - console.error('Failed to undo AI operation:', error); + wpawLog.error('Failed to undo AI operation:', error); setMessages((prev) => [...prev, { role: 'system', type: 'error', @@ -525,7 +568,7 @@ } setSeoAudit(data); } catch (error) { - console.error('SEO Audit error:', error); + wpawLog.error('SEO Audit error:', error); setMessages((prev) => [...prev, { role: 'assistant', content: `SEO Audit error: ${error.message}`, @@ -551,6 +594,7 @@ }, body: JSON.stringify({ postId: postId, + sessionId: currentSessionId, focusKeyword: postConfig.seo_focus_keyword, chatHistory: messages.filter(m => m.role !== 'system'), }), @@ -562,6 +606,7 @@ } const data = await response.json(); + applyProviderMetadata(data); if (data.meta_description) { updatePostConfig('seo_meta_description', data.meta_description); setMessages((prev) => [...prev, { @@ -573,7 +618,7 @@ throw new Error('No meta description returned from API'); } } catch (error) { - console.error('Error generating meta description:', error); + wpawLog.error('Error generating meta description:', error); setMessages((prev) => [...prev, { role: 'system', content: `❌ Failed to generate meta description: ${error.message}`, @@ -628,6 +673,19 @@ } }, [messages, isLoading]); + React.useEffect(() => { + const handleBeforeUnload = (event) => { + if (!isLoading) { + return; + } + event.preventDefault(); + event.returnValue = ''; + return ''; + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [isLoading]); + React.useEffect(() => { loadSectionBlocks(); }, [postId]); @@ -636,27 +694,153 @@ if (!postId) { return; } + try { + const savedSession = window.localStorage.getItem(`wpawSessionId_${postId}`); + if (savedSession) { + setCurrentSessionId(savedSession); + } + } catch (error) { + // Ignore storage read errors. + } + }, [postId]); + + React.useEffect(() => { + if (!postId || !currentSessionId) { + return; + } + try { + window.localStorage.setItem(`wpawSessionId_${postId}`, currentSessionId); + } catch (error) { + // Ignore storage write errors. + } + }, [postId, currentSessionId]); + + React.useEffect(() => { const loadChatHistory = async () => { try { - const response = await fetch(`${wpAgenticWriter.apiUrl}/chat-history/${postId}`, { - method: 'GET', - headers: { - 'X-WP-Nonce': wpAgenticWriter.nonce, - }, - }); - if (!response.ok) { - return; + const headers = { + 'X-WP-Nonce': wpAgenticWriter.nonce, + }; + let historyMessages = []; + let resolvedSessionId = ''; + + // Primary source: merged sessions list (post sessions + unassigned sessions). + const sessions = await loadPostSessions(); + if (sessions.length > 0) { + if (sessions.length > 0) { + let selected = sessions[0]; + const preferred = currentSessionId || (() => { + try { + return window.localStorage.getItem(`wpawSessionId_${postId}`) || ''; + } catch (error) { + return ''; + } + })(); + if (preferred) { + const match = sessions.find((s) => s?.session_id === preferred); + if (match) { + selected = match; + } + } + resolvedSessionId = selected?.session_id || ''; + if (Array.isArray(selected?.messages) && selected.messages.length > 0) { + historyMessages = selected.messages; + } + } } - const data = await response.json(); - if (data && Array.isArray(data.messages) && data.messages.length > 0) { - setMessages((prev) => (prev.length > 0 ? prev : data.messages)); + + // Canonical single-session endpoint fallback. + if (postId && !resolvedSessionId) { + const primary = await fetch(`${wpAgenticWriter.apiUrl}/conversation/${postId}`, { + method: 'GET', + headers, + }); + if (primary.ok) { + const data = await primary.json(); + if (data?.session_id) { + resolvedSessionId = data.session_id; + } + if (data && Array.isArray(data.messages) && data.messages.length > 0) { + historyMessages = data.messages; + } + } + } + + // Legacy endpoint fallback. + if (postId && historyMessages.length === 0) { + const legacy = await fetch(`${wpAgenticWriter.apiUrl}/chat-history/${postId}`, { + method: 'GET', + headers, + }); + if (legacy.ok) { + const legacyData = await legacy.json(); + if (legacyData && Array.isArray(legacyData.messages) && legacyData.messages.length > 0) { + historyMessages = legacyData.messages; + } + } + } + + if (historyMessages.length > 0) { + setMessages((prev) => (prev.length > 0 ? prev : historyMessages)); + } + if (resolvedSessionId) { + setCurrentSessionId(resolvedSessionId); } } catch (error) { // Ignore history load failures. } }; loadChatHistory(); - }, [postId]); + }, [postId, currentSessionId]); + + const loadPostSessions = async () => { + const headers = { + 'X-WP-Nonce': wpAgenticWriter.nonce, + }; + let postSessions = []; + let unassignedSessions = []; + + if (postId) { + const postRes = await fetch(`${wpAgenticWriter.apiUrl}/conversations/post/${postId}`, { + method: 'GET', + headers, + }); + if (postRes.ok) { + const postData = await postRes.json(); + postSessions = Array.isArray(postData?.sessions) ? postData.sessions : []; + } + } else { + // New post flow: include unassigned/auto-draft sessions for recovery. + const activeRes = await fetch(`${wpAgenticWriter.apiUrl}/conversations?status=active&limit=50`, { + method: 'GET', + headers, + }); + if (activeRes.ok) { + const activeData = await activeRes.json(); + const allActive = Array.isArray(activeData?.sessions) ? activeData.sessions : []; + unassignedSessions = allActive.filter((s) => { + const pid = Number(s?.post_id || 0); + const postStatus = String(s?.post_status || '').toLowerCase(); + return pid === 0 || postStatus === 'auto-draft'; + }); + } + } + + const merged = [...postSessions, ...unassignedSessions]; + const deduped = []; + const seen = new Set(); + merged.forEach((session) => { + const sid = session?.session_id || ''; + if (!sid || seen.has(sid)) { + return; + } + seen.add(sid); + deduped.push(session); + }); + + setAvailableSessions(deduped); + return deduped; + }; const resolveStreamTarget = (content) => { if (progressRegex.test(content)) { @@ -894,7 +1078,7 @@ const decoder = new TextDecoder(); const timeout = setTimeout(() => { if (isLoading) { - console.error('Generation timeout - no response received'); + wpawLog.error('Generation timeout - no response received'); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -991,7 +1175,10 @@ const content = item.innerHTML?.match(/
  • (.*?)<\/li>/)?.[1] || ''; return wp.blocks.createBlock('core/list-item', { content: content }); }); - newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems); + newBlock = wp.blocks.createBlock('core/list', { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false + }, listItems); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); @@ -1005,6 +1192,7 @@ insertBlocks(newBlock); } } else if (data.type === 'complete') { + applyProviderMetadata(data); clearTimeout(timeout); setCost({ ...cost, session: cost.session + data.totalCost }); @@ -1031,7 +1219,7 @@ }]); } } catch (parseError) { - console.error('Failed to parse streaming data:', line, parseError); + wpawLog.error('Failed to parse streaming data:', line, parseError); } } } @@ -1039,7 +1227,7 @@ clearTimeout(timeout); } catch (error) { - console.error('Article generation error:', error); + wpawLog.error('Article generation error:', error); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -1110,6 +1298,7 @@ body: JSON.stringify({ messages: [...chatHistory, { role: 'user', content: userMessage }], postId: postId, + sessionId: currentSessionId, type: 'chat', stream: true, postConfig: postConfig, @@ -1148,6 +1337,9 @@ return [...prev, { role: 'assistant', content: fullContent, isStreaming: true }]; }); } else if (data.type === 'complete') { + // Apply provider metadata from completion. + applyProviderMetadata(data); + setMessages(prev => { const lastMsg = prev[prev.length - 1]; if (lastMsg && lastMsg.role === 'assistant') { @@ -1155,7 +1347,7 @@ } return prev; }); - // Extract ALL focus keyword suggestions from completed response + // Extract ALL focus keyword suggestions from completed response. if (fullContent) { const suggestions = extractFocusKeywordSuggestions(fullContent); if (suggestions.length > 0) { @@ -1209,7 +1401,10 @@ if (blockType === 'core/list') { const items = content.split('\n').map((line) => line.trim()).filter(Boolean); const listItems = items.map((item) => wp.blocks.createBlock('core/list-item', { content: item })); - return wp.blocks.createBlock('core/list', { ordered: action.ordered || false }, listItems); + return wp.blocks.createBlock('core/list', { + ordered: action.ordered || false, + ...(action.start ? { start: parseInt(action.start, 10) } : {}) + }, listItems); } if (blockType === 'core/code') { @@ -1358,6 +1553,7 @@ }, body: JSON.stringify({ postId: postId, + sessionId: currentSessionId, sectionId: sectionId, blockIds: blockIds, }), @@ -1557,7 +1753,8 @@ body: JSON.stringify({ chatHistory: chatMessages, postId: postId, - }), + sessionId: currentSessionId, + }), }); if (!response.ok) { @@ -1565,9 +1762,10 @@ } const data = await response.json(); + applyProviderMetadata(data); if (data.tokens_saved > 0) { - console.log(`πŸ’‘ Context optimized: ~${data.tokens_saved} tokens saved (~$${(data.tokens_saved * 0.0000002).toFixed(4)})`); + wpawLog.log(`Context optimized: ~${data.tokens_saved} tokens saved (~$${(data.tokens_saved * 0.0000002).toFixed(4)})`); } return { @@ -1577,7 +1775,7 @@ tokensSaved: data.tokens_saved || 0, }; } catch (error) { - console.error('Summarization error:', error); + wpawLog.error('Summarization error:', error); return { summary: '', useFullHistory: true, cost: 0 }; } }; @@ -1600,7 +1798,8 @@ hasPlan: Boolean(currentPlanRef.current), currentMode: agentMode, postId: postId, - }), + sessionId: currentSessionId, + }), }); if (!response.ok) { @@ -1608,12 +1807,13 @@ } const data = await response.json(); + applyProviderMetadata(data); return { intent: data.intent || 'continue_chat', cost: data.cost || 0, }; } catch (error) { - console.error('Intent detection error:', error); + wpawLog.error('Intent detection error:', error); return { intent: 'continue_chat', cost: 0 }; } }; @@ -1665,7 +1865,7 @@ content: 'βœ… Context cleared. Starting fresh conversation.' }]); } catch (error) { - console.error('Reset error:', error); + wpawLog.error('Reset error:', error); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -1712,6 +1912,7 @@ }, body: JSON.stringify({ postId: postId, + sessionId: currentSessionId, title: plan.title, sections: plan.sections, }), @@ -1731,10 +1932,11 @@ updatePostConfig('seo_secondary_keywords', data.secondary_keywords.join(', ')); } - // Track cost + // Track cost and apply provider metadata if (data.cost) { setCost({ ...cost, session: cost.session + data.cost }); } + applyProviderMetadata(data); // Add assistant message about keyword suggestions setMessages(prev => [...prev, { @@ -1742,7 +1944,7 @@ content: `🎯 **SEO Keywords Suggested:**\n\n**Focus Keyword:** ${data.focus_keyword}\n\n**Secondary Keywords:** ${data.secondary_keywords.join(', ')}\n\n${data.reasoning || ''}\n\nYou can review and edit these in the Config panel before writing.` }]); } catch (error) { - console.error('Keyword suggestion error:', error); + wpawLog.error('Keyword suggestion error:', error); // Silently fail - don't interrupt the workflow } }; @@ -1791,7 +1993,8 @@ const { retry = false } = options; lastExecuteRequestRef.current = { postId: postId, - stream: true, + sessionId: currentSessionId, + stream: true, postConfig: postConfig, detectedLanguage: detectedLanguage, chatHistory: messages.filter(m => m.role !== 'system'), @@ -1957,6 +2160,7 @@ if (data.totalCost) { setCost({ ...cost, session: cost.session + data.totalCost }); } + applyProviderMetadata(data); setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); @@ -1977,7 +2181,7 @@ throw new Error(data.message || 'Failed to execute outline'); } } catch (parseError) { - console.error('Failed to parse streaming data:', line, parseError); + wpawLog.error('Failed to parse streaming data:', line, parseError); } } } @@ -2006,20 +2210,39 @@ return; } - const confirmMessage = 'Clear chat context? This removes the current sidebar conversation and resets the agent’s chat memory (including stored chat history) for this post. It won’t change your article content or outline.'; + const confirmMessage = 'Start a new conversation for this post? Your current conversation history will be kept and you can return to it later.'; if (!window.confirm(confirmMessage)) { return; } try { - await fetch(wpAgenticWriter.apiUrl + '/clear-context', { + setIsSessionActionLoading(true); + if (currentSessionId) { + await fetch(`${wpAgenticWriter.apiUrl}/conversations/${currentSessionId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpAgenticWriter.nonce, + }, + body: JSON.stringify({ status: 'completed' }), + }); + } + const response = await fetch(wpAgenticWriter.apiUrl + '/conversations', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, - body: JSON.stringify({ postId }), + body: JSON.stringify({ post_id: postId }), }); + if (!response.ok) { + throw new Error('Failed to create a new conversation'); + } + const data = await response.json(); + if (data?.session_id) { + setCurrentSessionId(data.session_id); + } + await loadPostSessions(); setMessages([]); setInClarification(false); setQuestions([]); @@ -2032,8 +2255,10 @@ setMessages(prev => [...prev, { role: 'system', type: 'error', - content: 'Error: Failed to clear chat context.', + content: 'Error: Failed to start a new conversation.', }]); + } finally { + setIsSessionActionLoading(false); } }; const createBlocksFromSerialized = (block) => { @@ -2144,7 +2369,8 @@ body: JSON.stringify({ blocks: blocksToReformat, postId: postId, - }), + sessionId: currentSessionId, + }), }); if (!response.ok) { @@ -2153,6 +2379,7 @@ } const data = await response.json(); + applyProviderMetadata(data); const results = data.results || []; const { replaceBlocks } = dispatch('core/block-editor'); const currentTitle = select('core/editor').getEditedPostAttribute('title') || ''; @@ -2225,6 +2452,7 @@ instruction: instruction, plan: existingPlan, postId: postId, + sessionId: currentSessionId, postConfig: postConfig, }), }); @@ -2242,6 +2470,7 @@ if (data.cost) { setCost({ ...cost, session: cost.session + data.cost }); } + applyProviderMetadata(data); setMessages(prev => { const newMessages = [...prev]; @@ -2395,7 +2624,7 @@ const currentHeading = (block.attributes?.content || '').trim().toLowerCase(); if (currentHeading === lastHeadingContent) { - console.log('WP Agentic Writer: Removed duplicate heading:', block.attributes.content); + wpawLog.log('WP Agentic Writer: Removed duplicate heading:', block.attributes.content); continue; } @@ -2700,6 +2929,7 @@ blocksToRefine: blocksToRefineData, // Send actual block objects allBlocks: normalizedAllBlocks, postId: postId, + sessionId: currentSessionId, stream: true, diffPlan: useDiffPlan, postConfig: postConfig, @@ -2791,6 +3021,9 @@ refinedCount++; } else if (data.type === 'complete') { + // Apply provider metadata from completion + applyProviderMetadata(data); + // Update timeline setMessages(prev => { const newMessages = [...prev]; @@ -2821,7 +3054,7 @@ }); } } catch (e) { - console.error('Failed to parse streaming data:', line, e); + wpawLog.error('Failed to parse streaming data:', line, e); } } if (refinementFailed) { @@ -3235,7 +3468,8 @@ body: JSON.stringify({ messages: [...chatHistory, { role: 'user', content: userMessage }], postId: postId, - type: 'chat', + sessionId: currentSessionId, + type: 'chat', stream: true, postConfig: postConfig, }), @@ -3309,6 +3543,7 @@ if (data.totalCost) { setCost({ ...cost, session: cost.session + data.totalCost }); } + applyProviderMetadata(data); // Extract ALL focus keyword suggestions from AI response setMessages(prev => { const lastAssistantMsg = prev.filter(m => m.role === 'assistant').pop(); @@ -3322,7 +3557,7 @@ }); } } catch (parseError) { - console.error('Failed to parse streaming data:', line, parseError); + wpawLog.error('Failed to parse streaming data:', line, parseError); } } } @@ -3347,7 +3582,7 @@ }); } } catch (intentError) { - console.error('Intent detection failed:', intentError); + wpawLog.error('Intent detection failed:', intentError); } } catch (error) { const errorMsg = error.message || 'Failed to chat'; @@ -3431,7 +3666,8 @@ topic: userMessage, answers: [], postId: postId, - mode: 'generation', + sessionId: currentSessionId, + mode: 'generation', postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), }), @@ -3472,7 +3708,7 @@ } // If clarity check fails, proceed with generation anyway } catch (clarityError) { - console.warn('Clarity check failed, proceeding with generation:', clarityError); + wpawLog.warn('Clarity check failed, proceeding with generation:', clarityError); // Continue to article generation } @@ -3503,7 +3739,8 @@ topic: userMessage, context: '', postId: postId, - answers: [], + sessionId: currentSessionId, + answers: [], autoExecute: agentMode !== 'planning', stream: true, articleLength: postConfig.article_length, @@ -3533,7 +3770,7 @@ // Add timeout to detect hanging responses const timeout = setTimeout(() => { if (isLoading) { - console.error('Generation timeout - no response received'); + wpawLog.error('Generation timeout - no response received'); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -3638,7 +3875,10 @@ const content = item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ''; return wp.blocks.createBlock('core/list-item', { content: content }); }); - newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems); + newBlock = wp.blocks.createBlock('core/list', { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false + }, listItems); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); @@ -3650,6 +3890,7 @@ insertBlocks(newBlock); } } else if (data.type === 'complete') { + applyProviderMetadata(data); clearTimeout(timeout); setCost({ ...cost, session: cost.session + data.totalCost }); @@ -3678,7 +3919,7 @@ setIsLoading(false); } } catch (parseError) { - console.error('Failed to parse streaming data:', line, parseError); + wpawLog.error('Failed to parse streaming data:', line, parseError); } } } @@ -3687,7 +3928,7 @@ clearTimeout(timeout); } catch (error) { clearTimeout(timeout); - console.error('Article generation error:', error); + wpawLog.error('Article generation error:', error); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -3755,6 +3996,7 @@ topic: userMessage, context: '', postId: postId, + sessionId: currentSessionId, answers: [], autoExecute: agentMode !== 'planning', stream: true, @@ -3868,7 +4110,10 @@ const content = item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ''; return wp.blocks.createBlock('core/list-item', { content: content }); }); - newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems); + newBlock = wp.blocks.createBlock('core/list', { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false + }, listItems); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); @@ -3883,6 +4128,7 @@ insertBlocks(newBlock); } } else if (data.type === 'complete') { + applyProviderMetadata(data); setCost({ ...cost, session: cost.session + data.totalCost }); // Update timeline to complete @@ -3913,7 +4159,8 @@ new CustomEvent('wpaw:open-image-review-modal', { detail: { postId: postId, - imageCount: imagePlaceholders.length + sessionId: currentSessionId, + imageCount: imagePlaceholders.length } }) ); @@ -3924,7 +4171,7 @@ throw new Error(data.message); } } catch (parseError) { - console.error('Failed to parse streaming data:', line, parseError); + wpawLog.error('Failed to parse streaming data:', line, parseError); } } } @@ -3981,7 +4228,7 @@ updatePostConfig('seo_secondary_keywords', configData.secondary_keywords); } } catch (e) { - console.error('Failed to parse config answers:', e); + wpawLog.error('Failed to parse config answers:', e); } } @@ -4023,6 +4270,7 @@ topic: topic, context: '', postId: postId, + sessionId: currentSessionId, clarificationAnswers: answers, autoExecute: agentMode !== 'planning', stream: true, @@ -4054,7 +4302,7 @@ // Add timeout to detect hanging responses const timeout = setTimeout(() => { if (isLoading) { - console.error('Generation timeout - no response received'); + wpawLog.error('Generation timeout - no response received'); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -4161,7 +4409,10 @@ const content = item.innerHTML?.match(/

  • (.*?)<\/li>/)?.[1] || ''; return wp.blocks.createBlock('core/list-item', { content: content }); }); - newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems); + newBlock = wp.blocks.createBlock('core/list', { + ...(data.block.attrs || {}), + ordered: data.block.attrs?.ordered || false + }, listItems); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); @@ -4173,6 +4424,7 @@ insertBlocks(newBlock); } } else if (data.type === 'complete') { + applyProviderMetadata(data); clearTimeout(timeout); setCost({ ...cost, session: cost.session + data.totalCost }); @@ -4202,7 +4454,7 @@ setIsLoading(false); } } catch (parseError) { - console.error('Failed to parse streaming data:', line, parseError); + wpawLog.error('Failed to parse streaming data:', line, parseError); } } } @@ -4211,7 +4463,7 @@ } } catch (error) { clearTimeout(timeout); - console.error('Article generation error:', error); + wpawLog.error('Article generation error:', error); setMessages(prev => [...prev, { role: 'system', type: 'error', @@ -4484,6 +4736,96 @@ ); }; + const startNewConversation = async () => { + if (isLoading || isSessionActionLoading) { + return; + } + try { + setIsSessionActionLoading(true); + const response = await fetch(wpAgenticWriter.apiUrl + '/conversations', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpAgenticWriter.nonce, + }, + body: JSON.stringify({ post_id: postId || 0 }), + }); + if (!response.ok) { + throw new Error('Failed to create a new conversation'); + } + const data = await response.json(); + if (data?.session_id) { + setCurrentSessionId(data.session_id); + } + await loadPostSessions(); + setMessages([]); + setShowWelcome(false); + } catch (error) { + setMessages(prev => [...prev, { + role: 'system', + type: 'error', + content: 'Error: Failed to start a new conversation.', + }]); + } finally { + setIsSessionActionLoading(false); + } + }; + + const deleteConversationSession = async (sessionId) => { + if (!sessionId || isSessionActionLoading) { + return; + } + if (!window.confirm('Delete this session permanently?')) { + return; + } + try { + setIsSessionActionLoading(true); + const response = await fetch(`${wpAgenticWriter.apiUrl}/conversations/${sessionId}`, { + method: 'DELETE', + headers: { + 'X-WP-Nonce': wpAgenticWriter.nonce, + }, + }); + if (!response.ok) { + throw new Error('Failed to delete session'); + } + const sessions = await loadPostSessions(); + if (currentSessionId === sessionId) { + const replacement = sessions[0]?.session_id || ''; + setCurrentSessionId(replacement); + setMessages(Array.isArray(sessions[0]?.messages) ? sessions[0].messages : []); + } + } catch (error) { + setMessages(prev => [...prev, { + role: 'system', + type: 'error', + content: 'Error: Failed to delete session.', + }]); + } finally { + setIsSessionActionLoading(false); + } + }; + + const getSessionDisplayTitle = (session, index) => { + if (session?.title && session.title.trim()) { + return session.title.trim(); + } + const firstUser = Array.isArray(session?.messages) + ? session.messages.find((m) => m?.role === 'user' && typeof m?.content === 'string' && m.content.trim()) + : null; + if (firstUser?.content) { + return firstUser.content.trim().slice(0, 56); + } + const updatedRaw = session?.updated_at || session?.last_activity || ''; + if (updatedRaw) { + const d = new Date(updatedRaw); + if (!Number.isNaN(d.getTime())) { + return `Session ${index + 1} - ${d.toLocaleDateString()}`; + } + } + return `Session ${index + 1}`; + }; + // Render Welcome Screen (chatty, friendly) const renderWelcomeScreen = () => { return wp.element.createElement('div', { className: 'wpaw-welcome-screen' }, @@ -4494,6 +4836,70 @@ }), wp.element.createElement('h2', { className: 'wpaw-welcome-title' }, 'Welcome to Agentic Writer'), wp.element.createElement('p', { className: 'wpaw-welcome-subtitle' }, "What's your concern today?"), + availableSessions.length > 0 && wp.element.createElement('div', { + className: 'wpaw-existing-sessions', + style: { marginBottom: '16px' } + }, + wp.element.createElement('div', { + style: { fontSize: '12px', opacity: 0.8, marginBottom: '8px' } + }, 'Continue a previous conversation'), + ...availableSessions.slice(0, 5).map((session, idx) => + wp.element.createElement('div', { + key: session.session_id || idx, + className: 'wpaw-welcome-pill', + style: { + width: '100%', + marginBottom: '6px', + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: '8px' + } + }, + wp.element.createElement('button', { + type: 'button', + style: { + flex: 1, + background: 'transparent', + border: 'none', + color: 'inherit', + textAlign: 'left', + cursor: 'pointer' + }, + onClick: () => { + setCurrentSessionId(session.session_id || ''); + setMessages(Array.isArray(session.messages) ? session.messages : []); + setShowWelcome(false); + } + }, + wp.element.createElement('div', null, getSessionDisplayTitle(session, idx)), + wp.element.createElement('div', { style: { opacity: 0.7, fontSize: '11px' } }, + `${Array.isArray(session.messages) ? session.messages.length : 0} msgs` + ) + ), + wp.element.createElement('button', { + type: 'button', + title: 'Delete session', + disabled: isSessionActionLoading, + style: { + background: 'transparent', + border: '1px solid rgba(255,255,255,0.25)', + color: 'inherit', + borderRadius: '6px', + padding: '2px 6px', + cursor: 'pointer' + }, + onClick: () => deleteConversationSession(session.session_id) + }, 'Γ—') + ) + ), + wp.element.createElement('button', { + className: 'wpaw-welcome-pill', + style: { width: '100%' }, + disabled: isSessionActionLoading, + onClick: startNewConversation + }, '+ Start New Conversation') + ), // Focus keyword input wp.element.createElement('input', { type: 'text', @@ -4634,6 +5040,12 @@ // Stats wp.element.createElement('div', { className: 'wpaw-fk-stats' }, wp.element.createElement('span', null, `πŸ’° $${(cost.session || 0).toFixed(4)}`), + providerInfo && wp.element.createElement('span', { + className: 'wpaw-provider-info', + title: providerInfo.warnings.length > 0 ? providerInfo.warnings.join('; ') : 'AI provider used' + }, + providerInfo.fallbackUsed ? ' ⚠️ ' + (providerInfo.provider || 'fallback') : ' πŸ“‘ ' + (providerInfo.provider || 'AI') + ), wp.element.createElement('span', { className: 'wpaw-fk-divider' }, 'β”‚'), wp.element.createElement('span', null, `πŸ“Š ~${messages.filter(m => m.role !== 'system').length * 500} tokens`) ) @@ -4670,7 +5082,13 @@ }) ), wp.element.createElement('span', { className: 'wpaw-fk-cost' }, - `$${(cost.session || 0).toFixed(4)}` + `$${(cost.session || 0).toFixed(4)}`, + providerInfo && wp.element.createElement('span', { + className: 'wpaw-provider-badge', + title: providerInfo.warnings.length > 0 ? providerInfo.warnings.join('; ') : 'AI provider' + }, + providerInfo.fallbackUsed ? '⚠' : 'πŸ“‘' + ) ), wp.element.createElement('button', { className: 'wpaw-fk-expand', @@ -4733,7 +5151,7 @@ // Call clarity check - MANDATORY before outline generation try { - console.log('[WPAW] Calling clarity check with topic:', topic); + wpawLog.log('[WPAW] Calling clarity check with topic:', topic); const clarityResponse = await fetch(wpAgenticWriter.apiUrl + '/check-clarity', { method: 'POST', headers: { @@ -4744,23 +5162,25 @@ topic: topic || 'article outline', answers: [], postId: postId, - mode: 'generation', + sessionId: currentSessionId, + mode: 'generation', postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), }), }); - console.log('[WPAW] Clarity response status:', clarityResponse.status); + wpawLog.log('[WPAW] Clarity response status:', clarityResponse.status); if (!clarityResponse.ok) { const errorText = await clarityResponse.text(); - console.error('[WPAW] Clarity check failed:', errorText); + wpawLog.error('[WPAW] Clarity check failed:', errorText); throw new Error('Clarity check failed: ' + errorText); } const clarityData = await clarityResponse.json(); + applyProviderMetadata(clarityData); const clarityResult = clarityData.result; - console.log('[WPAW] Clarity result:', clarityResult); + wpawLog.log('[WPAW] Clarity result:', clarityResult); if (clarityResult.detected_language) { setDetectedLanguage(clarityResult.detected_language); @@ -4768,7 +5188,7 @@ // MANDATORY: Always show quiz if questions exist if (clarityResult.questions && clarityResult.questions.length > 0) { - console.log('[WPAW] Showing quiz with', clarityResult.questions.length, 'questions'); + wpawLog.log('[WPAW] Showing quiz with', clarityResult.questions.length, 'questions'); setQuestions(clarityResult.questions); setInClarification(true); setCurrentQuestionIndex(0); @@ -4789,10 +5209,10 @@ }); return; // Stop here - quiz must be completed first } else { - console.warn('[WPAW] No questions returned from clarity check!'); + wpawLog.warn('[WPAW] No questions returned from clarity check!'); } } catch (clarityError) { - console.error('[WPAW] Clarity check error:', clarityError); + wpawLog.error('[WPAW] Clarity check error:', clarityError); // Show error to user instead of silently proceeding setMessages(prev => [...prev, { role: 'system', @@ -4829,7 +5249,8 @@ topic: topic || 'article outline', context: '', postId: postId, - answers: [], + sessionId: currentSessionId, + answers: [], autoExecute: false, stream: true, articleLength: postConfig.article_length, @@ -4892,7 +5313,7 @@ }); } } catch (parseError) { - console.error('Failed to parse streaming data:', parseError); + wpawLog.error('Failed to parse streaming data:', parseError); } } } @@ -5812,7 +6233,13 @@ wp.element.createElement('span', { className: 'wpaw-status-dot ' + agentStatus }), wp.element.createElement('span', { className: 'wpaw-status-label' }, statusLabels[agentStatus]) ), - wp.element.createElement('div', { className: 'wpaw-status-actions' }, + wp.element.createElement('div', { className: 'wpaw-status-actions' }, + !showWelcome && wp.element.createElement('button', { + className: 'wpaw-status-icon-btn', + title: 'Back to Sessions', + onClick: () => setShowWelcome(true), + disabled: isLoading + }, 'Sessions'), // Undo Button aiUndoStack.length > 0 && wp.element.createElement('button', { className: 'wpaw-status-icon-btn wpaw-undo-btn', @@ -5829,14 +6256,16 @@ className: 'wpaw-status-icon-btn', dangerouslySetInnerHTML: { __html: '' }, title: 'Configuration', - onClick: () => setActiveTab('config') + onClick: () => setActiveTab('config'), + disabled: isLoading }), // Cost Icon Button wp.element.createElement('button', { className: 'wpaw-status-icon-btn', dangerouslySetInnerHTML: { __html: '' }, title: 'Cost Tracking', - onClick: () => setActiveTab('cost') + onClick: () => setActiveTab('cost'), + disabled: isLoading }) ) ), @@ -6011,6 +6440,12 @@ ), wp.element.createElement('div', { className: 'wpaw-command-actions-group' }, + !showWelcome && wp.element.createElement('button', { + className: 'wpaw-command-text-btn', + type: 'button', + onClick: () => setShowWelcome(true), + disabled: isLoading, + }, 'Sessions'), // Clear Context (Bottom Middle-ish) wp.element.createElement('button', { className: 'wpaw-command-text-btn', @@ -6077,7 +6512,7 @@ setCostHistory(data.history); } } catch (e) { - console.error('Failed to refresh cost data:', e); + wpawLog.error('Failed to refresh cost data:', e); } }; @@ -6181,7 +6616,12 @@ }; // Main render. - return wp.element.createElement(PluginSidebar, { + return wp.element.createElement(wp.element.Fragment, null, + wp.element.createElement(PluginSidebarMoreMenuItem, { + target: 'wp-agentic-writer', + icon: pluginIcon, + }, 'WP Agentic Writer'), + wp.element.createElement(PluginSidebar, { name: 'wp-agentic-writer', title: wp.element.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } }, wp.element.createElement('img', { @@ -6199,7 +6639,7 @@ activeTab === 'cost' && renderCostTab() ) ) - ); + )); }; // HOC to get post ID. @@ -6210,16 +6650,9 @@ // Connect sidebar to Redux store. const ConnectedSidebar = wp.data.withSelect(mapSelectToProps)(AgenticWriterSidebar); - // Custom icon component using the SVG - const AgenticWriterIcon = () => wp.element.createElement('img', { - src: wpAgenticWriter.pluginUrl + '/assets/img/icon.svg', - alt: 'WP Agentic Writer', - style: { width: '20px', height: '20px' } - }); - // Register plugin. registerPlugin('wp-agentic-writer', { - icon: AgenticWriterIcon, + icon: pluginIcon, render: ConnectedSidebar, }); })(window.wp); diff --git a/includes/class-admin-columns.php b/includes/class-admin-columns.php index 8256f9e..5a60970 100644 --- a/includes/class-admin-columns.php +++ b/includes/class-admin-columns.php @@ -77,16 +77,10 @@ class WP_Agentic_Writer_Admin_Columns { return; } - global $wpdb; - $table_name = $wpdb->prefix . 'wpaw_cost_tracking'; - - // Get total cost for this post - $total_cost = $wpdb->get_var( - $wpdb->prepare( - "SELECT SUM(cost) FROM {$table_name} WHERE post_id = %d", - $post_id - ) - ); + $total_cost = 0.0; + if ( class_exists( 'WP_Agentic_Writer_Cost_Tracker' ) ) { + $total_cost = WP_Agentic_Writer_Cost_Tracker::get_instance()->get_session_total( $post_id ); + } if ( $total_cost && $total_cost > 0 ) { // Color code based on cost diff --git a/includes/class-gutenberg-sidebar.php b/includes/class-gutenberg-sidebar.php index e56490d..25d60a8 100644 --- a/includes/class-gutenberg-sidebar.php +++ b/includes/class-gutenberg-sidebar.php @@ -11,6 +11,23 @@ if ( ! defined( 'ABSPATH' ) ) { exit; } +/** + * Debug logging helper - only logs if WP_DEBUG is enabled. + * + * @param string $message Log message. + * @param mixed $data Optional data to log. + */ +function wpaw_debug_log( $message, $data = null ) { + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $prefix = '[WPAW Debug] '; + if ( null === $data ) { + error_log( $prefix . $message ); + } else { + error_log( $prefix . $message . ' ' . wp_json_encode( $data ) ); + } + } +} + /** * Class WP_Agentic_Writer_Gutenberg_Sidebar * @@ -69,9 +86,9 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { $dompurify_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/purify.min.js'; $markdown_task_lists_url = WP_AGENTIC_WRITER_URL . 'assets/js/vendor/markdown-it-task-lists.min.js'; - // Debug: Log the script URL. - error_log( 'WP Agentic Writer - Script URL: ' . $script_url ); - error_log( 'WP Agentic Writer - File exists: ' . ( file_exists( WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js' ) ? 'YES' : 'NO' ) ); + // Debug: Log the script URL (only when WP_DEBUG is on). + wpaw_debug_log( 'Script URL: ' . $script_url ); + wpaw_debug_log( 'File exists: ' . ( file_exists( WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js' ) ? 'YES' : 'NO' ) ); // Enqueue markdown renderer and sanitizer. wp_enqueue_script( @@ -96,6 +113,16 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { true ); + // Enqueue utility functions (loaded before main sidebar). + $utils_script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar-utils.js'; + wp_enqueue_script( + 'wp-agentic-writer-sidebar-utils', + WP_AGENTIC_WRITER_URL . 'assets/js/sidebar-utils.js', + array(), + file_exists( $utils_script_path ) ? filemtime( $utils_script_path ) : WP_AGENTIC_WRITER_VERSION, + true + ); + // Enqueue sidebar script. $script_path = WP_AGENTIC_WRITER_DIR . 'assets/js/sidebar.js'; @@ -114,6 +141,7 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { 'wp-agentic-writer-markdown-it', 'wp-agentic-writer-dompurify', 'wp-agentic-writer-markdown-task-lists', + 'wp-agentic-writer-sidebar-utils', ), file_exists( $script_path ) ? filemtime( $script_path ) : WP_AGENTIC_WRITER_VERSION, true @@ -178,6 +206,26 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { file_exists( $style_path ) ? filemtime( $style_path ) : WP_AGENTIC_WRITER_VERSION ); + // Enqueue agentic components styles. + $components_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/agentic-components.css'; + $components_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/agentic-components.css'; + wp_enqueue_style( + 'wp-agentic-writer-components', + $components_style_url, + array(), + file_exists( $components_style_path ) ? filemtime( $components_style_path ) : WP_AGENTIC_WRITER_VERSION + ); + + // Enqueue workflow styles. + $workflow_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/agentic-workflow.css'; + $workflow_style_url = WP_AGENTIC_WRITER_URL . 'assets/css/agentic-workflow.css'; + wp_enqueue_style( + 'wp-agentic-writer-workflow', + $workflow_style_url, + array(), + file_exists( $workflow_style_path ) ? filemtime( $workflow_style_path ) : WP_AGENTIC_WRITER_VERSION + ); + // Enqueue editor styles for image placeholders. $editor_style_path = WP_AGENTIC_WRITER_DIR . 'assets/css/editor.css'; wp_enqueue_style( @@ -225,20 +273,20 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { // Don't expose API key to frontend. unset( $settings['openrouter_api_key'] ); - // Ensure all required keys exist with defaults (6 models per model-preset-brief.md). + // Ensure all required keys exist with defaults from model registry. $defaults = array( - 'chat_model' => 'google/gemini-2.5-flash', - 'clarity_model' => 'google/gemini-2.5-flash', - 'planning_model' => 'google/gemini-2.5-flash', - 'writing_model' => 'anthropic/claude-3.5-sonnet', - 'refinement_model' => 'anthropic/claude-3.5-sonnet', - 'image_model' => 'openai/gpt-4o', + 'chat_model' => WPAW_Model_Registry::get_default_model( 'chat' ), + 'clarity_model' => WPAW_Model_Registry::get_default_model( 'clarity' ), + 'planning_model' => WPAW_Model_Registry::get_default_model( 'planning' ), + 'writing_model' => WPAW_Model_Registry::get_default_model( 'writing' ), + 'refinement_model' => WPAW_Model_Registry::get_default_model( 'refinement' ), + 'image_model' => WPAW_Model_Registry::get_default_model( 'image' ), 'web_search_enabled' => false, 'search_engine' => 'auto', 'search_depth' => 'medium', 'cost_tracking_enabled' => true, 'monthly_budget' => 600, - 'settings_url' => admin_url( 'options-general.php?page=wp-agentic-writer' ), + 'settings_url' => admin_url( 'options-general.php?page=wp-agentic-writer-settings' ), 'preferred_languages' => array( 'auto', 'English', 'Indonesian' ), 'custom_languages' => array(), ); @@ -295,7 +343,7 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { 'permission_callback' => array( $this, 'check_permissions' ), ) ); - // Chat history endpoint. + // Chat history endpoint (deprecated - for backward compatibility only). register_rest_route( 'wp-agentic-writer/v1', '/chat-history/(?P\d+)', @@ -306,6 +354,17 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { ) ); + // Conversation session endpoint (canonical for chat hydration). + register_rest_route( + 'wp-agentic-writer/v1', + '/conversation/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_conversation_by_post' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + // Post config endpoints. register_rest_route( 'wp-agentic-writer/v1', @@ -487,6 +546,50 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { ) ); + // Multi-pass refinement endpoint. + register_rest_route( + 'wp-agentic-writer/v1', + '/refine-multi-pass', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_refine_multi_pass' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Article-wide refinement endpoint. + register_rest_route( + 'wp-agentic-writer/v1', + '/refine-article', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_refine_article' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // GEO scoring endpoint. + register_rest_route( + 'wp-agentic-writer/v1', + '/geo-score/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_geo_score' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Proactive suggestions endpoint (idle analysis) + register_rest_route( + 'wp-agentic-writer/v1', + '/suggest-improvements', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_suggest_improvements' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + // Detect intent endpoint. register_rest_route( 'wp-agentic-writer/v1', @@ -528,6 +631,305 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { 'permission_callback' => array( $this, 'check_permissions' ), ) ); + + // Writing state persistence endpoint. + register_rest_route( + 'wp-agentic-writer/v1', + '/writing-state/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_writing_state' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/writing-state/(?P\d+)', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_save_writing_state' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Generate title endpoint (uses WP 7.0 AI Client when available). + register_rest_route( + 'wp-agentic-writer/v1', + '/generate-title', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_generate_title' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Generate excerpt endpoint (uses WP 7.0 AI Client when available). + register_rest_route( + 'wp-agentic-writer/v1', + '/generate-excerpt', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_generate_excerpt' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // AI capabilities status endpoint. + register_rest_route( + 'wp-agentic-writer/v1', + '/ai-capabilities', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_ai_capabilities' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Brave Search endpoint for research. + register_rest_route( + 'wp-agentic-writer/v1', + '/search', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_search' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Fetch web content endpoint for research. + register_rest_route( + 'wp-agentic-writer/v1', + '/fetch-content', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_fetch_content' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Research summary endpoint. + register_rest_route( + 'wp-agentic-writer/v1', + '/research-summary', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_research_summary' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Conversation sessions endpoints. + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_conversations' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations/post/(?P\d+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_conversations' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_create_conversation' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations/(?P[a-zA-Z0-9]+)', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_conversation' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations/(?P[a-zA-Z0-9]+)', + array( + 'methods' => 'PUT', + 'callback' => array( $this, 'handle_update_conversation' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations/(?P[a-zA-Z0-9]+)', + array( + 'methods' => 'DELETE', + 'callback' => array( $this, 'handle_delete_conversation' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations/(?P[a-zA-Z0-9]+)/messages', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_update_conversation_messages' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/conversations/(?P[a-zA-Z0-9]+)/link-post', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_link_conversation_to_post' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // Legacy migration endpoint for converting post meta chat history to sessions + register_rest_route( + 'wp-agentic-writer/v1', + '/migrate-chat-history/(?P\d+)', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_migrate_chat_history' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + + // User preferences endpoints (per-user settings) + register_rest_route( + 'wp-agentic-writer/v1', + '/user-preferences', + array( + 'methods' => 'GET', + 'callback' => array( $this, 'handle_get_user_preferences' ), + 'permission_callback' => '__return_true', + ) + ); + register_rest_route( + 'wp-agentic-writer/v1', + '/user-preferences', + array( + 'methods' => 'POST', + 'callback' => array( $this, 'handle_save_user_preferences' ), + 'permission_callback' => array( $this, 'check_permissions' ), + ) + ); + } + + /** + * Handle get writing state request. + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_writing_state( $request ) { + $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; + if ( $post_id <= 0 ) { + return new WP_Error( + 'invalid_post', + __( 'Invalid post ID.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Authorization: Check if user can edit this specific post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $state = array( + 'status' => get_post_meta( $post_id, '_wpaw_writing_status', true ) ?: 'idle', + 'current_section_index' => (int) get_post_meta( $post_id, '_wpaw_current_section', true ) ?: 0, + 'sections_written' => get_post_meta( $post_id, '_wpaw_sections_written', true ) ?: array(), + 'last_updated' => get_post_meta( $post_id, '_wpaw_writing_state_updated', true ) ?: null, + 'plan_id' => get_post_meta( $post_id, '_wpaw_plan_id', true ) ?: null, + 'resume_token' => get_post_meta( $post_id, '_wpaw_resume_token', true ) ?: null, + ); + + return new WP_REST_Response( $state, 200 ); + } + + /** + * Handle save writing state request. + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_save_writing_state( $request ) { + $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; + if ( $post_id <= 0 ) { + return new WP_Error( + 'invalid_post', + __( 'Invalid post ID.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Authorization: Check if user can edit this specific post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to modify this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $params = $request->get_json_params(); + + // Validate status against allowed values. + $allowed_statuses = array( 'idle', 'in_progress', 'paused', 'completed', 'failed' ); + $status = sanitize_text_field( $params['status'] ?? 'idle' ); + if ( ! in_array( $status, $allowed_statuses, true ) ) { + $status = 'idle'; + } + + // Save writing status + update_post_meta( $post_id, '_wpaw_writing_status', $status ); + + // Save current section index + $section_index = (int) ( $params['current_section_index'] ?? 0 ); + update_post_meta( $post_id, '_wpaw_current_section', $section_index ); + + // Save sections written array + $sections_written = is_array( $params['sections_written'] ?? null ) + ? array_map( 'sanitize_text_field', $params['sections_written'] ) + : array(); + update_post_meta( $post_id, '_wpaw_sections_written', $sections_written ); + + // Save plan ID + $plan_id = sanitize_text_field( $params['plan_id'] ?? '' ); + update_post_meta( $post_id, '_wpaw_plan_id', $plan_id ); + + // Save resume token + $resume_token = sanitize_text_field( $params['resume_token'] ?? '' ); + update_post_meta( $post_id, '_wpaw_resume_token', $resume_token ); + + // Update timestamp + update_post_meta( $post_id, '_wpaw_writing_state_updated', current_time( 'mysql' ) ); + + $state = array( + 'status' => $status, + 'current_section_index' => $section_index, + 'sections_written' => $sections_written, + 'last_updated' => current_time( 'mysql' ), + 'plan_id' => $plan_id, + ); + + return new WP_REST_Response( $state, 200 ); } /** @@ -540,6 +942,118 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { return current_user_can( 'edit_posts' ); } + /** + * Check post-specific edit permissions. + * + * @since 0.1.3 + * @param int $post_id Post ID to check. + * @return bool True if user can edit the post. + */ + public function check_post_permission( $post_id ) { + if ( $post_id <= 0 ) { + return false; + } + return current_user_can( 'edit_post', $post_id ); + } + + /** + * Resolve session ID from request, or auto-create a post-linked session. + * + * @param string $session_id Existing session ID from request. + * @param int $post_id Post ID. + * @return string + */ + private function resolve_or_create_session_id( $session_id, $post_id ) { + $session_id = sanitize_text_field( (string) $session_id ); + if ( '' !== $session_id ) { + return $session_id; + } + + $post_id = (int) $post_id; + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + if ( $post_id <= 0 ) { + $created_unassigned = $manager->create_session( + array( + 'post_id' => 0, + 'title' => 'Unassigned Session - ' . current_time( 'Y-m-d H:i' ), + ) + ); + return is_wp_error( $created_unassigned ) ? '' : (string) $created_unassigned; + } + + $existing = $manager->get_session_by_post_id( $post_id ); + if ( $existing && ! empty( $existing['session_id'] ) ) { + return (string) $existing['session_id']; + } + + $created = $manager->create_session( + array( + 'post_id' => $post_id, + 'title' => 'Post ' . $post_id . ' Session', + ) + ); + return is_wp_error( $created ) ? '' : (string) $created; + } + + /** + * Build provider metadata for responses. + * + * @since 0.1.4 + * @param WPAW_Provider_Selection_Result $provider_result Provider selection result. + * @param string $model Model identifier used. + * @return array Provider metadata. + */ + private function build_provider_metadata( $provider_result, $model = '' ) { + return array( + 'provider' => $provider_result->actual_provider ?? 'unknown', + 'selected_provider' => $provider_result->selected_provider ?? $provider_result->actual_provider ?? 'unknown', + 'fallback_used' => ! empty( $provider_result->fallback_used ), + 'warnings' => $provider_result->warnings ?? array(), + 'model' => $model, + ); + } + + /** + * Track AI cost with full metadata. + * + * This helper ensures all cost tracking includes provider, session, and status + * metadata consistently. Use this instead of raw do_action calls. + * + * @since 0.2.0 + * @param int $post_id Post ID. + * @param string $model Model used. + * @param string $action Action type (chat, planning, execution, etc). + * @param int $input_tokens Input token count. + * @param int $output_tokens Output token count. + * @param float $cost Cost in USD. + * @param mixed $provider_result Provider selection result or provider name string. + * @param string $session_id Session ID (optional). + * @param string $status Status (success, error) (optional, defaults to 'success'). + */ + private function track_ai_cost( $post_id, $model, $action, $input_tokens, $output_tokens, $cost, $provider_result, $session_id = '', $status = 'success' ) { + // Handle both provider result objects and plain strings + if ( is_object( $provider_result ) && isset( $provider_result->actual_provider ) ) { + $actual_provider = $provider_result->actual_provider; + } elseif ( is_string( $provider_result ) ) { + $actual_provider = $provider_result; + } else { + $actual_provider = 'unknown'; + } + + do_action( + 'wp_aw_after_api_request', + $post_id, + $model, + $action, + $input_tokens, + $output_tokens, + $cost, + $actual_provider, + $session_id, + $status + ); + } + /** * Handle chat request. * @@ -553,6 +1067,17 @@ class WP_Agentic_Writer_Gutenberg_Sidebar { $post_id = $params['postId'] ?? 0; $type = $params['type'] ?? 'planning'; $stream = ! empty( $params['stream'] ); + $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); + + // Check post permission if post_id is provided. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $post_config = $this->resolve_post_config_from_request( $params, $post_id ); $post_config_context = $this->build_post_config_context( $post_config ); @@ -593,12 +1118,14 @@ CRITICAL LANGUAGE REQUIREMENT: $messages = $this->prepend_system_prompt( $messages, $system_prompt ); - // Get provider for this task type. - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type ); + // Get provider for this task type with selection metadata. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type ); + $provider = $provider_result->provider; + $provider_warnings = $provider_result->warnings; if ( $stream ) { $web_search_options = $this->get_web_search_options( $post_config ); - $this->stream_chat_request( $messages, $post_id, $type, $web_search_options ); + $this->stream_chat_request( $messages, $post_id, $type, $web_search_options, $session_id ); exit; } @@ -613,26 +1140,50 @@ CRITICAL LANGUAGE REQUIREMENT: ); } - // Track cost (always track for debugging, even if cost is 0). - do_action( - 'wp_aw_after_api_request', + // Track cost with provider and session metadata. + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'chat', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + $session_id, + 'success' ); + // Include provider metadata in response (DoD Provider Transparency contract). + $response['provider'] = $provider_result->actual_provider; + $response['selected_provider'] = $provider_result->selected_provider; + $response['fallback_used'] = $provider_result->fallback_used; + $response['warnings'] = $provider_warnings; + $response['session_id'] = $session_id; + // Also include nested form for consistency with other AI endpoints + $response['provider_metadata'] = $this->build_provider_metadata( $provider_result, $response['model'] ?? '' ); + if ( ! empty( $response['content'] ) ) { - $this->update_post_chat_history( $post_id, $last_user_message, $response['content'] ); - $this->update_post_memory( - $post_id, - array( - 'last_prompt' => $last_user_message, - 'last_intent' => 'chat', - ) - ); + // Storage: Persist to session table via Context Service only. + // Legacy _wpaw_chat_history post meta is deprecated and no longer written. + if ( ! empty( $session_id ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message( + $session_id, + array( + 'role' => 'user', + 'content' => $last_user_message, + 'timestamp' => current_time( 'c' ), + ) + ); + $context_service->add_message( + $session_id, + array( + 'role' => 'assistant', + 'content' => $response['content'], + 'timestamp' => current_time( 'c' ), + ) + ); + } } return new WP_REST_Response( $response, 200 ); @@ -642,13 +1193,14 @@ CRITICAL LANGUAGE REQUIREMENT: * Stream chat request response. * * @since 0.1.0 - * @param array $messages Chat messages. - * @param int $post_id Post ID. - * @param string $type Chat type. - * @param array $web_search_options Web search options. + * @param array $messages Chat messages. + * @param int $post_id Post ID. + * @param string $type Chat type. + * @param array $web_search_options Web search options. + * @param string $session_id Session ID for context persistence. * @return void */ - private function stream_chat_request( $messages, $post_id, $type, $web_search_options = array() ) { + private function stream_chat_request( $messages, $post_id, $type, $web_search_options = array(), $session_id = '' ) { header( 'Content-Type: text/event-stream' ); header( 'Cache-Control: no-cache' ); header( 'X-Accel-Buffering: no' ); @@ -661,10 +1213,16 @@ CRITICAL LANGUAGE REQUIREMENT: } flush(); - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type ); + // Initialize streaming state variables. $accumulated_content = ''; - $total_cost = 0; $chunks_emitted = 0; + $total_cost = 0; + $last_user_message = $this->get_last_user_message( $messages ); + + // Get provider with selection metadata for transparency. + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( $type ); + $provider = $provider_result->provider; + $provider_warnings = $provider_result->warnings; $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); @@ -715,18 +1273,25 @@ CRITICAL LANGUAGE REQUIREMENT: $total_cost = $response['cost'] ?? 0; - // Debug: Log chat cost tracking - error_log( 'WP Agentic Writer: Tracking chat cost - post_id=' . $post_id . ', model=' . ($response['model'] ?? 'unknown') . ', type=' . $type . ', cost=' . $total_cost . ', input_tokens=' . ($response['input_tokens'] ?? 0) . ', output_tokens=' . ($response['output_tokens'] ?? 0) ); + // Debug: Log chat cost tracking (only when WP_DEBUG is on) + wpaw_debug_log( 'Tracking chat cost', array( + 'post_id' => $post_id, + 'model' => $response['model'] ?? 'unknown', + 'type' => $type, + 'cost' => $total_cost + ) ); - // Always track chat cost (even if 0 for debugging) - do_action( - 'wp_aw_after_api_request', + // Track cost with provider and session metadata. + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'chat', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $total_cost + $total_cost, + $provider_result, + $session_id, + 'success' ); if ( ! empty( $accumulated_content ) ) { @@ -738,21 +1303,38 @@ CRITICAL LANGUAGE REQUIREMENT: ) . "\n\n"; flush(); - $last_user_message = $this->get_last_user_message( $messages ); - $this->update_post_chat_history( $post_id, $last_user_message, $accumulated_content ); - $this->update_post_memory( - $post_id, - array( - 'last_prompt' => $last_user_message, - 'last_intent' => 'chat', - ) - ); + // Storage: Persist to session table via Context Service only. + // Legacy _wpaw_chat_history post meta is deprecated and no longer written. + if ( ! empty( $session_id ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message( + $session_id, + array( + 'role' => 'user', + 'content' => $last_user_message, + 'timestamp' => current_time( 'c' ), + ) + ); + $context_service->add_message( + $session_id, + array( + 'role' => 'assistant', + 'content' => $accumulated_content, + 'timestamp' => current_time( 'c' ), + ) + ); + } } + // Send provider transparency metadata in completion event. echo "data: " . wp_json_encode( array( - 'type' => 'complete', - 'totalCost' => $total_cost, + 'type' => 'complete', + 'totalCost' => $total_cost, + 'session_id' => $session_id, + 'provider' => $provider_result->actual_provider, + 'fallback_used' => $provider_result->fallback_used, + 'warnings' => $provider_warnings, ) ) . "\n\n"; flush(); @@ -768,6 +1350,7 @@ CRITICAL LANGUAGE REQUIREMENT: public function handle_clear_context( $request ) { $params = $request->get_json_params(); $post_id = intval( $params['postId'] ?? 0 ); + $session_id = sanitize_text_field( $params['sessionId'] ?? '' ); if ( $post_id <= 0 ) { return new WP_Error( @@ -777,8 +1360,17 @@ CRITICAL LANGUAGE REQUIREMENT: ); } - delete_post_meta( $post_id, '_wpaw_memory' ); - delete_post_meta( $post_id, '_wpaw_chat_history' ); + // Check post permission before clearing context. + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + // Use the context service to clear the session and post meta consistently. + $this->context_service->clear_context( $session_id, $post_id ); return new WP_REST_Response( array( @@ -789,9 +1381,11 @@ CRITICAL LANGUAGE REQUIREMENT: } /** - * Get chat history for a post. + * Get chat history for a post (deprecated compatibility endpoint). * * @since 0.1.0 + * @deprecated 0.2.0 Use /wp-agentic-writer/v1/conversation/{post_id} instead. + * This endpoint reads from conversation sessions via migration. * @param WP_REST_Request $request REST request. * @return WP_REST_Response|WP_Error Response. */ @@ -805,10 +1399,93 @@ CRITICAL LANGUAGE REQUIREMENT: ); } + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $history = $this->get_post_chat_history( $post_id ); return new WP_REST_Response( array( 'messages' => $history, + 'deprecated' => true, + 'message' => 'This endpoint is deprecated. Use conversation sessions instead.', + ), + 200 + ); + } + + /** + * Handle get conversation by post ID request (canonical endpoint). + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_get_conversation_by_post( $request ) { + $post_id = intval( $request['post_id'] ?? 0 ); + if ( $post_id <= 0 ) { + return new WP_Error( + 'invalid_post', + __( 'Invalid post ID.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $session = $manager->get_session_by_post_id( $post_id ); + + if ( ! $session ) { + // Check for legacy post-meta chat history and migrate if present. + $legacy_history = get_post_meta( $post_id, '_wpaw_chat_history', true ); + if ( ! empty( $legacy_history ) && is_array( $legacy_history ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id ); + + // Fetch the newly created session after migration. + $session = $manager->get_session_by_post_id( $post_id ); + if ( $session ) { + return new WP_REST_Response( + array( + 'messages' => $session['messages'], + 'has_session' => true, + 'session_id' => $session['session_id'], + 'post_id' => $session['post_id'], + 'migrated' => true, + 'deprecated' => false, + ), + 200 + ); + } + } + + return new WP_REST_Response( + array( + 'messages' => array(), + 'has_session' => false, + ), + 200 + ); + } + + return new WP_REST_Response( + array( + 'messages' => $session['messages'], + 'has_session' => true, + 'session_id' => $session['session_id'], + 'post_id' => $session['post_id'], + 'deprecated' => false, ), 200 ); @@ -818,51 +1495,24 @@ CRITICAL LANGUAGE REQUIREMENT: * Update per-post chat history. * * @since 0.1.0 + * @deprecated 0.1.4 Use conversation sessions instead. This method no longer writes + * to post meta; it exists only for backward compatibility. * @param int $post_id Post ID. * @param string $user_message User message. * @param string $assistant_message Assistant message. * @return void */ private function update_post_chat_history( $post_id, $user_message, $assistant_message ) { - if ( $post_id <= 0 ) { - return; - } - - $history = get_post_meta( $post_id, '_wpaw_chat_history', true ); - if ( ! is_array( $history ) ) { - $history = array(); - } - - if ( $user_message ) { - $history[] = array( - 'role' => 'user', - 'content' => $user_message, - ); - } - if ( $assistant_message ) { - $history[] = array( - 'role' => 'assistant', - 'content' => $assistant_message, - ); - } - - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $limit = isset( $settings['chat_history_limit'] ) ? absint( $settings['chat_history_limit'] ) : 20; - $limit = min( $limit, 200 ); - - if ( 0 === $limit ) { - update_post_meta( $post_id, '_wpaw_chat_history', array() ); - return; - } - - $history = array_slice( $history, -$limit ); - update_post_meta( $post_id, '_wpaw_chat_history', $history ); + // Deprecated - now only used for migration reads. Do not write. + // New code should use conversation sessions. + return; } /** * Get per-post chat history. * * @since 0.1.0 + * @deprecated 0.1.4 Use conversation sessions instead. * @param int $post_id Post ID. * @return array */ @@ -871,12 +1521,39 @@ CRITICAL LANGUAGE REQUIREMENT: return array(); } + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $sessions = $manager->get_sessions_for_post( $post_id ); + + // If we have active sessions, return messages from the most recent one + if ( ! empty( $sessions ) ) { + // Sort by last activity, most recent first + usort( $sessions, function( $a, $b ) { + return strtotime( $b['last_activity'] ?? '' ) - strtotime( $a['last_activity'] ?? '' ); + } ); + + $active_session = $sessions[0]; + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context = $context_service->get_context( $active_session['session_id'], $post_id ); + return $context['messages'] ?? array(); + } + + // No sessions found - check for legacy history and migrate $history = get_post_meta( $post_id, '_wpaw_chat_history', true ); - if ( ! is_array( $history ) ) { + if ( ! is_array( $history ) || empty( $history ) ) { return array(); } - return array_values( $history ); + // Legacy data exists - trigger migration + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $migrated_session_id = $context_service->migrate_legacy_chat_history( $post_id ); + + // Return migrated data using the returned session id + if ( ! empty( $migrated_session_id ) ) { + $context = $context_service->get_context( $migrated_session_id, $post_id ); + return $context['messages'] ?? array(); + } + + return array(); } /** @@ -1195,8 +1872,23 @@ CRITICAL LANGUAGE REQUIREMENT: return; } - // Only inject if the provider doesn't natively support OpenRouter's web search routing plugins - if ( $provider instanceof WP_Agentic_Writer_OpenRouter_Provider ) { + // Check if Brave API key is configured + $settings = get_option( 'wp_agentic_writer_settings', array() ); + $brave_api_key = $settings['brave_search_api_key'] ?? ''; + + // Determine search strategy: + // 1. If Brave API key is set -> Use Brave (regardless of provider) + // 2. If using OpenRouter without Brave key -> Let OpenRouter's online models handle it + // 3. If using Local Backend without Brave key -> No search available + + if ( empty( $brave_api_key ) && $provider instanceof WP_Agentic_Writer_OpenRouter_Provider ) { + // No Brave API key with OpenRouter - let the model's built-in search handle it + // OpenRouter's online models (e.g., gemini-2.5-flash-online) have search tools built-in + return; + } + + if ( empty( $brave_api_key ) ) { + // Local Backend or other providers without Brave API key return; } @@ -1214,15 +1906,15 @@ CRITICAL LANGUAGE REQUIREMENT: $brave_search = WP_Agentic_Writer_Brave_Search_API::get_instance(); $results = $brave_search->search( $last_query, 3 ); - + if ( ! is_wp_error( $results ) && ! empty( $results ) ) { $context_markdown = $brave_search->format_results_for_llm( $results, $last_query ); - + $injection_message = array( 'role' => 'system', 'content' => $context_markdown ); - + $injected = false; for( $i = count( $messages ) - 1; $i >= 0; $i-- ) { if ( 'user' === $messages[ $i ]['role'] ) { @@ -1231,7 +1923,7 @@ CRITICAL LANGUAGE REQUIREMENT: break; } } - + if ( ! $injected ) { array_unshift( $messages, $injection_message ); } @@ -1272,6 +1964,14 @@ CRITICAL LANGUAGE REQUIREMENT: ); } + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + return new WP_REST_Response( $this->get_post_config( $post_id ), 200 ); } @@ -1292,6 +1992,14 @@ CRITICAL LANGUAGE REQUIREMENT: ); } + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $params = $request->get_json_params(); $config = $this->sanitize_post_config( $params['postConfig'] ?? array() ); update_post_meta( $post_id, '_wpaw_post_config', $config ); @@ -1352,6 +2060,15 @@ CRITICAL LANGUAGE REQUIREMENT: ); } + // Check post permission if post_id is provided. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + // Build chat history context for continuity. $chat_history_context = ''; if ( ! empty( $chat_history ) && is_array( $chat_history ) ) { @@ -1373,7 +2090,8 @@ CRITICAL LANGUAGE REQUIREMENT: } // Get provider for planning task. - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); + $provider = $provider_result->provider; // Build prompt for plan generation. $plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' ); @@ -1435,7 +2153,12 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); $response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' ); + // Debug: log the provider type and response + $provider_class = get_class( $provider ); + wpaw_debug_log( 'Plan generation using provider: ' . $provider_class ); + if ( is_wp_error( $response ) ) { + wpaw_debug_log( 'Plan generation error: ' . $response->get_error_message() ); return new WP_Error( 'plan_generation_error', $response->get_error_message(), @@ -1444,10 +2167,14 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar } // Extract JSON from response. - $content = $response['content']; + $content = $response['content'] ?? ''; $plan_json = $this->extract_json( $content ); + // Debug: log the raw response + wpaw_debug_log( 'Plan generation raw response length: ' . strlen( $content ) ); + if ( null === $plan_json ) { + wpaw_debug_log( 'extract_json returned null. Content length: ' . strlen( $content ) ); return new WP_Error( 'invalid_json', __( 'Failed to generate valid plan JSON.', 'wp-agentic-writer' ), @@ -1472,15 +2199,17 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar ); } - // Track cost (always track for debugging). - do_action( - 'wp_aw_after_api_request', + // Track cost with provider metadata. + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'planning', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + '', + 'success' ); return new WP_REST_Response( @@ -1488,6 +2217,10 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar 'plan' => $plan_json, 'cost' => $response['cost'] ?? 0, 'web_search_results' => $response['web_search_results'] ?? array(), + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -1505,11 +2238,6 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $instruction = $params['instruction'] ?? ''; $plan = $params['plan'] ?? array(); $post_id = $params['postId'] ?? 0; - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); - $post_config_context = $this->build_post_config_context( $post_config ); - $effective_language = $this->resolve_language_preference( $post_config, get_post_meta( $post_id, '_wpaw_detected_language', true ) ); - $plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' ); - $web_search_options = $this->get_web_search_options( $post_config ); if ( empty( $instruction ) ) { return new WP_Error( @@ -1527,7 +2255,24 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar ); } - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); + // Check post permission BEFORE reading post data. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + // Only read post config/meta after permission check. + $post_config = $this->resolve_post_config_from_request( $params, $post_id ); + $post_config_context = $this->build_post_config_context( $post_config ); + $effective_language = $this->resolve_language_preference( $post_config, get_post_meta( $post_id, '_wpaw_detected_language', true ) ); + $plan_language_instruction = $this->build_language_instruction( $effective_language, 'article plan (title, section headings, descriptions)' ); + $web_search_options = $this->get_web_search_options( $post_config ); + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'planning' ); + $provider = $provider_result->provider; $memory_context = $this->get_post_memory_context( $post_id ); $system_prompt = "You are an expert content strategist. Revise the provided outline based on the user's instruction. @@ -1618,21 +2363,27 @@ Rules: ); } - // Track cost (always track for debugging). - do_action( - 'wp_aw_after_api_request', + // Track cost with provider metadata. + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'planning', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + '', + 'success' ); return new WP_REST_Response( array( 'plan' => $plan_json, 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -1755,7 +2506,8 @@ Rules: } flush(); - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider = $provider_result->provider; $total_cost = 0; $post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) ); $post_config_context = $this->build_post_config_context( $post_config ); @@ -1908,14 +2660,14 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar ), ); - // Log the request for debugging - error_log( 'WP Agentic Writer: Calling OpenRouter API for planning. Topic: ' . substr( $topic, 0, 100 ) ); - error_log( 'WP Agentic Writer: Detected language: ' . $detected_language ); + // Log the request for debugging (only when WP_DEBUG is on) + wpaw_debug_log( 'Calling OpenRouter API for planning. Topic: ' . substr( $topic, 0, 100 ) ); + wpaw_debug_log( 'Detected language: ' . $detected_language ); $this->maybe_inject_brave_search( $messages, $provider, $web_search_options ); $response = $provider->chat( $messages, array_merge( array( 'temperature' => 0.7 ), $web_search_options ), 'planning' ); - error_log( 'WP Agentic Writer: OpenRouter API response received' ); + wpaw_debug_log( 'OpenRouter API response received' ); if ( is_wp_error( $response ) ) { echo "data: " . wp_json_encode( @@ -1929,9 +2681,11 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar } $content = $response['content']; + wpaw_debug_log( 'stream_generatePlan content length: ' . strlen( $content ) ); $plan_json = $this->extract_json( $content ); if ( null === $plan_json ) { + wpaw_debug_log( 'extract_json failed in streaming. Content length: ' . strlen( $content ) ); echo "data: " . wp_json_encode( array( 'type' => 'error', @@ -1962,14 +2716,16 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar $total_cost += $response['cost']; // Track plan cost. - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'], 'planning', $response['input_tokens'], $response['output_tokens'], - $response['cost'] + $response['cost'], + $provider_result, + '', + 'success' ); // Send plan data. @@ -1989,9 +2745,15 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar } else { $this->send_status( 'plan_complete', 'Outline ready.' ); echo "data: " . wp_json_encode( - array( - 'type' => 'complete', - 'totalCost' => $total_cost, + array_merge( + array( + 'type' => 'complete', + 'totalCost' => $total_cost, + ), + $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ) ) ) . "\n\n"; flush(); @@ -2032,7 +2794,7 @@ Keep sections focused and actionable. Include H2 headings only. For technical ar ); if ( is_wp_error( $update_result ) ) { - error_log( 'WP Agentic Writer: Failed to update post title - ' . $update_result->get_error_message() ); + wpaw_debug_log( 'Failed to update post title: ' . $update_result->get_error_message() ); } // Restore filters @@ -2180,7 +2942,7 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati ); // Log before calling streaming API - error_log( 'WP Agentic Writer: Starting section generation: ' . $heading ); + wpaw_debug_log( 'Starting section generation: ' . $heading ); // Send heading block first (but NOT for first section to avoid duplication with post title) if ( ! $is_first_section && $heading ) { @@ -2205,7 +2967,7 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati $divider_found = false; $markdown_content = ''; // Store complete markdown for later parsing - error_log( 'WP Agentic Writer: Calling OpenRouter streaming API' ); + wpaw_debug_log( 'Calling OpenRouter streaming API' ); $response = $provider->chat_stream( $messages, @@ -2296,18 +3058,24 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati $section_cost = $response['cost'] ?? 0; $total_cost += $section_cost; - // Debug: Log execution cost tracking - error_log( 'WP Agentic Writer: Tracking execution cost - post_id=' . $post_id . ', model=' . ($response['model'] ?? 'unknown') . ', cost=' . $section_cost . ', input_tokens=' . ($response['input_tokens'] ?? 0) . ', output_tokens=' . ($response['output_tokens'] ?? 0) ); + // Debug: Log execution cost tracking (only when WP_DEBUG is on) + wpaw_debug_log( 'Tracking execution cost', array( + 'post_id' => $post_id, + 'model' => $response['model'] ?? 'unknown', + 'cost' => $section_cost + ) ); // Track execution cost for this section. - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'execution', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $section_cost + $section_cost, + $provider_result, + $session_id ?? '', + 'success' ); // NOW parse the complete markdown content and send blocks @@ -2378,9 +3146,15 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati // Send completion message. echo "data: " . wp_json_encode( - array( - 'type' => 'complete', - 'totalCost' => $total_cost, + array_merge( + array( + 'type' => 'complete', + 'totalCost' => $total_cost, + ), + $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ) ) ) . "\n\n"; flush(); @@ -2407,6 +3181,7 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati public function handle_execute_article( $request ) { $params = $request->get_json_params(); $post_id = $params['postId'] ?? 0; + $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); $stream = $params['stream'] ?? false; $recommended_title = ''; $chat_history = $params['chatHistory'] ?? array(); @@ -2416,6 +3191,20 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati $detected_language = $params['detectedLanguage'] ?? $stored_language; $effective_language = $this->resolve_language_preference( $post_config, $detected_language ); + // Auto-save post and link conversation if needed (only for post_id = 0) + if ( empty( $post_id ) && ! empty( $session_id ) ) { + $post_id = $this->ensure_conversation_linked_to_post( $session_id, $post_id ); + } + + // Check post permission if post_id is provided. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + // Get plan from post meta. $plan = get_post_meta( $post_id, '_wpaw_plan', true ); @@ -2428,7 +3217,25 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati } if ( $stream ) { - $this->stream_execute_article( $plan, $post_id, $post_config, $effective_language ); + // For streaming, link conversation to post BEFORE getting plan from meta + if ( empty( $post_id ) && ! empty( $session_id ) ) { + $post_id = $this->ensure_conversation_linked_to_post( $session_id, $post_id ); + } + + // Now get plan after potentially having a valid post_id + $plan = get_post_meta( $post_id, '_wpaw_plan', true ); + if ( empty( $plan ) ) { + echo "data: " . wp_json_encode( + array( + 'type' => 'error', + 'message' => 'No plan found. Please generate a plan first.', + ) + ) . "\n\n"; + flush(); + return; + } + + $this->stream_execute_article( $plan, $post_id, $post_config, $effective_language, $session_id ); exit; } @@ -2453,7 +3260,8 @@ Remember: You MUST include the ~~~ARTICLE~~~ divider to separate your conversati } // Get provider for writing task. - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider = $provider_result->provider; $image_instruction = "IMAGE SUGGESTIONS: - Suggest where images would enhance understanding @@ -2634,14 +3442,16 @@ IMAGE SUGGESTIONS: } // Track total cost. - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $provider->get_execution_model(), 'execution', 0, 0, - $total_cost + $total_cost, + $provider_result, + '', + 'success' ); return new WP_REST_Response( @@ -2649,6 +3459,10 @@ IMAGE SUGGESTIONS: 'blocks' => $blocks, 'cost' => $total_cost, 'recommended_title' => $recommended_title, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $provider->get_execution_model() + ), ), 200 ); @@ -2658,21 +3472,37 @@ IMAGE SUGGESTIONS: * Stream article execution from a stored plan. * * @since 0.1.0 - * @param array $plan Plan data. - * @param int $post_id Post ID. + * @param array $plan Plan data. + * @param int $post_id Post ID. + * @param array $post_config Post configuration. + * @param string $effective_language Effective language. + * @param string $session_id Session ID for conversation linking. * @return void */ - private function stream_execute_article( $plan, $post_id, $post_config = array(), $effective_language = 'english' ) { + private function stream_execute_article( $plan, $post_id, $post_config = array(), $effective_language = 'english', $session_id = '' ) { header( 'Content-Type: text/event-stream' ); header( 'Cache-Control: no-cache' ); header( 'X-Accel-Buffering: no' ); - if ( ob_get_level() > 0 ) { + // Aggressively disable ALL output buffering layers (WordPress nests multiple) + @ini_set( 'output_buffering', 'Off' ); + @ini_set( 'zlib.output_compression', false ); + while ( ob_get_level() > 0 ) { ob_end_flush(); } flush(); - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider = $provider_result->provider; + $settings = get_option( 'wp_agentic_writer_settings', array() ); + wpaw_debug_log( 'Using provider', array( + 'class' => get_class( $provider ), + 'base_url' => property_exists( $provider, 'base_url' ) ? $provider->base_url : 'N/A' + ) ); + wpaw_debug_log( 'Settings check', array( + 'local_backend_url' => $settings['local_backend_url'] ?? 'NOT SET', + 'task_providers[writing]' => $settings['task_providers']['writing'] ?? 'NOT SET' + ) ); $total_cost = 0; $post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) ); $post_config_context = $this->build_post_config_context( $post_config ); @@ -2848,6 +3678,10 @@ IMAGE SUGGESTIONS: ); $accumulated_content = ''; + wpaw_debug_log( 'Starting section generation', array( + 'heading' => $heading, + 'section_position' => $section_position + ) ); $response = $provider->chat_stream( $messages, array( 'temperature' => 0.8 ), @@ -2856,6 +3690,7 @@ IMAGE SUGGESTIONS: $accumulated_content = $full_content; } ); + wpaw_debug_log( 'Section generation complete. accumulated_content length: ' . strlen( $accumulated_content ) ); if ( is_wp_error( $response ) ) { echo "data: " . wp_json_encode( @@ -2871,20 +3706,23 @@ IMAGE SUGGESTIONS: $section_cost = $response['cost'] ?? 0; $total_cost += $section_cost; - // Track cost for this section + // Track cost for this section. if ( $section_cost > 0 ) { - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? 'unknown', 'execution', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $section_cost + $section_cost, + $provider_result, + $session_id ?? '', + 'success' ); } if ( ! empty( $accumulated_content ) ) { + error_log( 'WP Agentic Writer: Parsing and sending blocks for section: ' . $heading ); $section_blocks = WP_Agentic_Writer_Markdown_Parser::parse( $accumulated_content ); foreach ( $section_blocks as $block ) { echo "data: " . wp_json_encode( @@ -2896,6 +3734,8 @@ IMAGE SUGGESTIONS: ) . "\n\n"; flush(); } + } else { + error_log( 'WP Agentic Writer: WARNING - No accumulated content for section: ' . $heading ); } $plan['sections'][ $section_position ]['status'] = 'done'; @@ -2927,6 +3767,10 @@ IMAGE SUGGESTIONS: array( 'type' => 'complete', 'totalCost' => $total_cost, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $provider->get_execution_model() + ), ) ) . "\n\n"; flush(); @@ -2954,6 +3798,15 @@ IMAGE SUGGESTIONS: ); } + // Check post permission if post_id is provided. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $results = array(); if ( $post_id > 0 ) { @@ -3038,8 +3891,18 @@ IMAGE SUGGESTIONS: ); } + // Check post permission if post_id is provided. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + // Get provider for writing task. - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider = $provider_result->provider; $messages = array( array( @@ -3055,6 +3918,18 @@ IMAGE SUGGESTIONS: $response = $provider->chat( $messages, array( 'temperature' => 0.8 ), 'execution' ); if ( is_wp_error( $response ) ) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model( 'writing' ), + 'regeneration', + 0, + 0, + 0, + $provider_result, + '', + 'error' + ); return new WP_Error( 'regeneration_error', $response->get_error_message(), @@ -3063,20 +3938,26 @@ IMAGE SUGGESTIONS: } // Track cost (always track for debugging). - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'regeneration', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + '', + 'success' ); return new WP_REST_Response( array( 'content' => $response['content'], 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -3092,6 +3973,15 @@ IMAGE SUGGESTIONS: public function handle_get_cost_tracking( $request ) { $post_id = $request->get_param( 'post_id' ); + // Check post-specific permission if post_id is provided. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $cost_tracker = WP_Agentic_Writer_Cost_Tracker::get_instance(); $data = $cost_tracker->get_frontend_data( $post_id ); @@ -3107,6 +3997,8 @@ IMAGE SUGGESTIONS: */ private function extract_json( $string ) { // Try to find JSON in the string. + + // Method 1: Standard JSON object if ( preg_match( '/\{.*\}/s', $string, $matches ) ) { $json = json_decode( $matches[0], true ); if ( json_last_error() === JSON_ERROR_NONE ) { @@ -3114,6 +4006,20 @@ IMAGE SUGGESTIONS: } } + // Method 2: JSON wrapped in markdown code block + if ( preg_match( '/```(?:json)?\s*\n?(.*?)\n?```/s', $string, $matches ) ) { + $json = json_decode( $matches[1], true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + return $json; + } + } + + // Method 3: Just try to decode the whole string + $json = json_decode( $string, true ); + if ( json_last_error() === JSON_ERROR_NONE ) { + return $json; + } + return null; } @@ -3171,13 +4077,6 @@ IMAGE SUGGESTIONS: $post_id = $params['postId'] ?? 0; $mode = $params['mode'] ?? 'generation'; $chat_history = $params['chatHistory'] ?? array(); - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); - $post_config_context = $this->build_post_config_context( $post_config ); - $preferred_language = $this->resolve_language_preference( $post_config, '' ); - $language_hint = ''; - if ( 'auto' !== ( $post_config['language'] ?? 'auto' ) ) { - $language_hint = "\n\nPreferred language: {$preferred_language}. Ask questions in that language."; - } if ( empty( $topic ) ) { return new WP_Error( @@ -3187,7 +4086,26 @@ IMAGE SUGGESTIONS: ); } - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + // Check post permission BEFORE reading post data. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + // Only read post config after permission check. + $post_config = $this->resolve_post_config_from_request( $params, $post_id ); + $post_config_context = $this->build_post_config_context( $post_config ); + $preferred_language = $this->resolve_language_preference( $post_config, '' ); + $language_hint = ''; + if ( 'auto' !== ( $post_config['language'] ?? 'auto' ) ) { + $language_hint = "\n\nPreferred language: {$preferred_language}. Ask questions in that language."; + } + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider = $provider_result->provider; // Get settings. $settings = get_option( 'wp_agentic_writer_settings', array() ); @@ -3366,6 +4284,18 @@ No markdown, no explanation - just JSON."; $response = $provider->chat( $messages, array( 'temperature' => 0.7 ), 'planning' ); if ( is_wp_error( $response ) ) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model( 'clarity' ), + 'clarity_check', + 0, + 0, + 0, + $provider_result, + '', + 'error' + ); // Log error and use default questions instead of failing. error_log( 'WP Agentic Writer: Clarity check API error - ' . $response->get_error_message() ); $result = $this->get_default_clarification_questions( $topic ); @@ -3388,6 +4318,18 @@ No markdown, no explanation - just JSON."; $result = $this->extract_json( $content ); if ( null === $result ) { + // Track parse failure for observability. + $this->track_ai_cost( + $post_id, + $response['model'] ?? 'unknown', + 'clarity_check', + $response['input_tokens'] ?? 0, + $response['output_tokens'] ?? 0, + $response['cost'] ?? 0, + $provider_result, + '', + 'error' + ); // Log parse error and use default questions instead of failing. error_log( 'WP Agentic Writer: Failed to parse clarity check JSON' ); $result = $this->get_default_clarification_questions( $topic ); @@ -3407,14 +4349,16 @@ No markdown, no explanation - just JSON."; // Track cost (always track for debugging). $post_id = $params['postId'] ?? 0; - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'clarity_check', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + '', + 'success' ); // MANDATORY: Always add configuration questions @@ -3432,6 +4376,10 @@ No markdown, no explanation - just JSON."; array( 'result' => $result, 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -3558,7 +4506,6 @@ No markdown, no explanation - just JSON."; $post_id = $params['postId'] ?? 0; $stream = $params['stream'] ?? false; $chat_history = $params['chatHistory'] ?? array(); - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); if ( empty( $block_content ) || empty( $refinement_request ) ) { return new WP_Error( @@ -3568,12 +4515,25 @@ No markdown, no explanation - just JSON."; ); } + // Check post permission BEFORE reading post data. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + // Only read post config after permission check. + $post_config = $this->resolve_post_config_from_request( $params, $post_id ); + // If streaming is requested, use streaming response. if ( $stream ) { return $this->stream_block_refine( $block_id, $block_type, $block_content, $refinement_request, $article_context, $post_id, $post_config ); } - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); + $provider = $provider_result->provider; // Build context from article structure. $context_str = "\n\nArticle Context:\n"; @@ -3667,14 +4627,16 @@ Keep the same block type (paragraph, heading, list, etc.)."; $blocks = WP_Agentic_Writer_Markdown_Parser::parse( $response['content'] ); // Track cost (always track for debugging). - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'block_refinement', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + $session_id, + 'success' ); return new WP_REST_Response( @@ -3682,6 +4644,10 @@ Keep the same block type (paragraph, heading, list, etc.)."; 'blocks' => $blocks, 'blockId' => $block_id, 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -3711,7 +4677,8 @@ Keep the same block type (paragraph, heading, list, etc.)."; } flush(); - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'refinement' ); + $provider = $provider_result->provider; $post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) ); $post_config_context = $this->build_post_config_context( $post_config ); $stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true ); @@ -3790,14 +4757,16 @@ Output format: } // Track cost (always track for debugging). - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'block_refinement', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + $session_id ?? '', + 'success' ); $payload = $this->parse_refined_payload( $response['content'] ); @@ -3893,12 +4862,16 @@ Output format: // Small delay for visual effect usleep( 100000 ); - // Send completion message. + // Send completion message with provider metadata. echo "data: " . wp_json_encode( array( 'type' => 'complete', 'blockId' => $block_id, 'totalCost' => $response['cost'], + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ) ) . "\n\n"; flush(); @@ -4196,10 +5169,10 @@ No markdown, no explanation - just JSON."; $message = $params['topic'] ?? ''; $selected_block = $params['selectedBlockClientId'] ?? ''; $post_id = $params['postId'] ?? 0; + $session_id = $this->resolve_or_create_session_id( $params['sessionId'] ?? '', $post_id ); $blocks_to_refine = $params['blocksToRefine'] ?? array(); $all_blocks = $params['allBlocks'] ?? array(); $diff_plan = ! empty( $params['diffPlan'] ); - $post_config = $this->resolve_post_config_from_request( $params, $post_id ); if ( empty( $blocks_to_refine ) || ! is_array( $blocks_to_refine ) ) { return new WP_Error( @@ -4209,8 +5182,20 @@ No markdown, no explanation - just JSON."; ); } + // Check post permission BEFORE reading post data. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + // Only read post config after permission check. + $post_config = $this->resolve_post_config_from_request( $params, $post_id ); + // Stream refinement for each mentioned block - $this->stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config ); + $this->stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config, $session_id ); // Return early to avoid REST API trying to send headers after streaming exit; @@ -4237,6 +5222,14 @@ No markdown, no explanation - just JSON."; ); } + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $block_ids = array_values( array_filter( array_map( 'sanitize_text_field', $block_ids ) @@ -4278,6 +5271,14 @@ No markdown, no explanation - just JSON."; ); } + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $mapping = get_post_meta( $post_id, '_wpaw_section_blocks', true ); if ( ! is_array( $mapping ) ) { $mapping = array(); @@ -4301,7 +5302,7 @@ No markdown, no explanation - just JSON."; * @param int $post_id Post ID. * @return void Streams response to client. */ - private function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = array() ) { + private function stream_refinement_from_chat( $blocks_to_refine, $message, $selected_block, $post_id, $all_blocks, $diff_plan, $post_config = array(), $session_id = '' ) { // Set headers for streaming. header( 'Content-Type: text/event-stream' ); header( 'Cache-Control: no-cache' ); @@ -4324,7 +5325,8 @@ No markdown, no explanation - just JSON."; ); } - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'writing' ); + $provider = $provider_result->provider; $post_config = $this->sanitize_post_config( wp_parse_args( $post_config, $this->get_default_post_config() ) ); $post_config_context = $this->build_post_config_context( $post_config ); $stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true ); @@ -4404,14 +5406,16 @@ Blocks: // Track cost for edit plan generation $plan_cost = $plan_response['cost'] ?? 0; if ( $plan_cost > 0 ) { - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $plan_response['model'] ?? '', 'refinement_planning', $plan_response['input_tokens'] ?? 0, $plan_response['output_tokens'] ?? 0, - $plan_cost + $plan_cost, + $provider_result, + $session_id ?? '', + 'success' ); } @@ -4560,14 +5564,16 @@ Output format: // Track cost from streaming result (always track for debugging). $stream_cost = $stream_result['cost'] ?? 0; $total_cost += $stream_cost; - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $stream_result['model'] ?? '', 'block_refinement', $stream_result['input_tokens'] ?? 0, $stream_result['output_tokens'] ?? 0, - $stream_cost + $stream_cost, + $provider_result, + $session_id ?? '', + 'success' ); // Parse and clean the response @@ -4593,12 +5599,37 @@ Output format: usleep( 100000 ); } - // Send completion message + // Persist refinement exchange in session history so msg counts remain accurate. + if ( ! empty( $session_id ) && ! empty( $message ) ) { + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $context_service->add_message( + $session_id, + array( + 'role' => 'user', + 'content' => sanitize_text_field( $message ), + 'timestamp' => current_time( 'c' ), + ) + ); + $context_service->add_message( + $session_id, + array( + 'role' => 'assistant', + 'content' => sprintf( 'Refined %d block(s) based on your request.', (int) $refined_count ), + 'timestamp' => current_time( 'c' ), + ) + ); + } + + // Send completion message with provider metadata. echo "data: " . wp_json_encode( array( 'type' => 'complete', 'refined' => $refined_count, 'totalCost' => $total_cost, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $stream_result['model'] ?? '' + ), ) ) . "\n\n"; flush(); @@ -5068,6 +6099,15 @@ Output format: ); } + // Check post permission before reading post content/config. + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $post = get_post( $post_id ); if ( ! $post ) { return new WP_Error( @@ -5184,6 +6224,17 @@ Output format: // Cap score at 100 $audit['score'] = min( 100, $audit['score'] ); + // Convert checks to issues for frontend compatibility + $audit['issues'] = array(); + foreach ( $audit['checks'] as $check ) { + if ( $check['status'] !== 'good' ) { + $audit['issues'][] = array( + 'severity' => $check['status'], + 'message' => $check['name'] . ': ' . $check['message'], + ); + } + } + return new WP_REST_Response( $audit, 200 ); } @@ -5298,7 +6349,8 @@ Output format: } $language_instruction = $this->build_language_instruction( $effective_language, 'meta description' ); - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider = $provider_result->provider; $prompt = "Generate a compelling meta description for SEO. Requirements:\n"; $prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n"; @@ -5339,14 +6391,16 @@ Output format: // Track cost $cost = $response['cost'] ?? 0; if ( $cost > 0 ) { - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? 'unknown', 'meta_description', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $cost + $cost, + $provider_result, + $session_id ?? '', + 'success' ); } @@ -5372,6 +6426,15 @@ Output format: $focus_keyword = $params['focusKeyword'] ?? ''; $chat_history = $params['chatHistory'] ?? array(); + // Check post permission BEFORE reading post content. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + if ( empty( $content ) && $post_id > 0 ) { $post = get_post( $post_id ); if ( $post ) { @@ -5410,7 +6473,8 @@ Output format: } } - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider = $provider_result->provider; $prompt = "Generate a compelling meta description for SEO. Requirements:\n"; $prompt .= "- Length: MAXIMUM 155 characters (STRICT - count every character including spaces)\n"; @@ -5446,17 +6510,19 @@ Output format: $meta_description = substr( $meta_description, 0, 152 ) . '...'; } - // Track cost for meta description generation + // Track cost for meta description generation. $cost = $response['cost'] ?? 0; if ( $cost > 0 && $post_id > 0 ) { - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? 'unknown', 'meta_description', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $cost + $cost, + $provider_result, + '', + 'success' ); } @@ -5465,6 +6531,10 @@ Output format: 'meta_description' => $meta_description, 'length' => strlen( $meta_description ), 'cost' => $cost, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -5491,6 +6561,15 @@ Output format: ); } + // Check post permission before reading post data. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + // Get detected language from post meta or config $stored_language = get_post_meta( $post_id, '_wpaw_detected_language', true ); $post_config = $this->get_post_config( $post_id ); @@ -5514,6 +6593,10 @@ Output format: 'secondary_keywords' => $result['secondary_keywords'], 'reasoning' => $result['reasoning'], 'cost' => $result['cost'], + 'provider_metadata' => $this->build_provider_metadata( + $result['provider_result'] ?? null, + $result['model'] ?? '' + ), ), 200 ); @@ -5531,6 +6614,15 @@ Output format: $chat_history = $params['chatHistory'] ?? array(); $post_id = $params['postId'] ?? 0; + // Check post permission before using postId for cost tracking. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + // Short history doesn't need summarization if ( empty( $chat_history ) || count( $chat_history ) < 4 ) { return new WP_REST_Response( @@ -5576,7 +6668,8 @@ Conversation: {$history_text}"; // Call AI with clarity model for language detection - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider = $provider_result->provider; $messages = array( array( 'role' => 'user', @@ -5595,15 +6688,17 @@ Conversation: $summary_tokens = $response['output_tokens'] ?? 100; $tokens_saved = $original_tokens - $summary_tokens; - // Track cost - do_action( - 'wp_aw_after_api_request', + // Track cost. + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'summarize_context', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + '', + 'success' ); return new WP_REST_Response( @@ -5612,6 +6707,10 @@ Conversation: 'use_full_history' => false, 'cost' => $response['cost'] ?? 0, 'tokens_saved' => $tokens_saved, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -5631,6 +6730,15 @@ Conversation: $current_mode = $params['currentMode'] ?? 'chat'; $post_id = $params['postId'] ?? 0; + // Check post permission before using postId for cost tracking. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + if ( empty( $last_message ) ) { return new WP_REST_Response( array( 'intent' => 'continue_chat' ), @@ -5645,8 +6753,9 @@ Conversation: 1. \"create_outline\" - User wants to create an article outline/structure 2. \"start_writing\" - User wants to write the full article 3. \"refine_content\" - User wants to improve existing content -4. \"continue_chat\" - User wants to continue discussing/exploring -5. \"clarify\" - User is asking questions or needs clarification +4. \"add_section\" - User wants to add a new section to existing outline or article +5. \"continue_chat\" - User wants to continue discussing/exploring +6. \"clarify\" - User is asking questions or needs clarification Consider: - The user's explicit request @@ -5658,7 +6767,8 @@ User's message: \"{$last_message}\" Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; // Call AI with clarity model for intent detection - $provider = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider = $provider_result->provider; $messages = array( array( 'role' => 'user', @@ -5673,14 +6783,16 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; } // Track cost - do_action( - 'wp_aw_after_api_request', + $this->track_ai_cost( $post_id, $response['model'] ?? '', 'detect_intent', $response['input_tokens'] ?? 0, $response['output_tokens'] ?? 0, - $response['cost'] ?? 0 + $response['cost'] ?? 0, + $provider_result, + $session_id ?? '', + 'success' ); // Clean up response @@ -5688,7 +6800,7 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; $intent = str_replace( '"', '', $intent ); // Validate intent - $valid_intents = array( 'create_outline', 'start_writing', 'refine_content', 'continue_chat', 'clarify' ); + $valid_intents = array( 'create_outline', 'start_writing', 'refine_content', 'add_section', 'continue_chat', 'clarify' ); if ( ! in_array( $intent, $valid_intents, true ) ) { $intent = 'continue_chat'; } @@ -5697,6 +6809,222 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; array( 'intent' => $intent, 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), + ), + 200 + ); + } + + /** + * Handle suggest improvements request (proactive AI suggestions). + * + * Analyzes article content and suggests improvements based on + * idle detection trigger. + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_suggest_improvements( $request ) { + $params = $request->get_json_params(); + $post_id = isset( $params['postId'] ) ? (int) $params['postId'] : 0; + $suggestion_types = $params['types'] ?? array( 'clarity', 'depth', 'structure' ); + + if ( $post_id <= 0 ) { + return new WP_Error( + 'invalid_post', + __( 'Valid post ID is required.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Check post permission before reading post content. + if ( ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + // Get post content for analysis + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + __( 'Post not found.', 'wp-agentic-writer' ), + array( 'status' => 404 ) + ); + } + + $blocks = parse_blocks( $post->post_content ); + $plain_content = ''; + $block_count = 0; + + foreach ( $blocks as $block ) { + if ( ! empty( $block['blockName'] ) && 0 === strpos( $block['blockName'], 'core/' ) ) { + $block_content = ''; + if ( 'core/paragraph' === $block['blockName'] || 'core/heading' === $block['blockName'] ) { + $block_content = $block['attrs']['content'] ?? ''; + } elseif ( 'core/list' === $block['blockName'] ) { + $inner_html = $block['innerHTML'] ?? ''; + $block_content = wp_strip_all_tags( $inner_html ); + } else { + $block_content = $block['innerHTML'] ?? ''; + $block_content = wp_strip_all_tags( $block_content ); + } + + if ( ! empty( $block_content ) ) { + $plain_content .= $block_content . "\n\n"; + $block_count++; + } + } + } + + if ( empty( $plain_content ) || $block_count < 3 ) { + return new WP_REST_Response( + array( + 'suggestions' => array(), + 'message' => 'Not enough content to analyze yet.', + ), + 200 + ); + } + + // Get post config for context + $post_config = $this->get_post_config( $post_id ); + $focus_keyword = $post_config['seo_focus_keyword'] ?? ''; + + // Build suggestion type instruction + $type_instruction = ''; + foreach ( $suggestion_types as $type ) { + switch ( $type ) { + case 'clarity': + $type_instruction .= "- Identify sentences or paragraphs that are too complex or confusing\n"; + break; + case 'depth': + $type_instruction .= "- Suggest areas where more examples, data, or explanation would improve the content\n"; + break; + case 'structure': + $type_instruction .= "- Identify missing sections or structural improvements needed for the article\n"; + break; + case 'engagement': + $type_instruction .= "- Suggest ways to increase reader engagement (questions, examples, calls to action)\n"; + break; + case 'seo': + if ( ! empty( $focus_keyword ) ) { + $type_instruction .= "- Check keyword '{$focus_keyword}' usage: suggest where to naturally include it\n"; + } + break; + } + } + + $system_prompt = "You are an expert content editor providing constructive improvement suggestions. + +Analyze the provided article content and suggest 1-3 specific improvements. +{$type_instruction} + +IMPORTANT GUIDELINES: +- Be specific about WHERE in the content the issue is (e.g., 'paragraph 3', 'the section about X') +- Be actionable - tell the user WHAT they should change and WHY +- Be concise - each suggestion should be 1-2 sentences max +- Prioritize the most impactful improvements +- NEVER suggest adding fluff or padding - only genuine improvements + +Return your response as valid JSON in this format: +{ + 'suggestions': [ + { + 'type': 'clarity|depth|structure|engagement|seo', + 'location': 'Brief description of where in the article', + 'issue': 'What the problem is', + 'suggestion': 'What to do instead', + 'priority': 'high|medium|low' + } + ], + 'summary': 'One sentence summary of the overall article quality' +} + +If the content is already excellent and needs no major improvements, return an empty suggestions array with a positive summary. +Only suggest changes that would genuinely improve the reader's experience or search engine performance."; + + $messages = array( + array( + 'role' => 'system', + 'content' => $system_prompt, + ), + array( + 'role' => 'user', + 'content' => "Please analyze this article and suggest improvements:\n\n{$plain_content}", + ), + ); + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task( 'clarity' ); + $provider = $provider_result->provider; + $response = $provider->chat( $messages, array(), 'analysis' ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + // Track cost with full nine-argument contract including provider attribution. + $cost = $response['cost'] ?? 0; + if ( $cost > 0 ) { + $actual_provider = 'unknown'; + if ( is_object( $provider_result ) && isset( $provider_result->actual_provider ) ) { + $actual_provider = $provider_result->actual_provider; + } + + // Get session ID for this post if available. + $session_id = ''; + if ( $post_id > 0 ) { + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $session = $manager->get_session_by_post_id( $post_id ); + if ( $session && isset( $session['session_id'] ) ) { + $session_id = $session['session_id']; + } + } + + $this->track_ai_cost( + $post_id, + $response['model'] ?? '', + 'analysis', + $response['input_tokens'] ?? 0, + $response['output_tokens'] ?? 0, + $cost, + $actual_provider, + $session_id, + 'success' + ); + } + + // Parse JSON from response + $content = $response['content'] ?? ''; + $suggestions_json = $this->extract_json( $content ); + + if ( null === $suggestions_json ) { + // If JSON parsing fails, return a generic success with no suggestions + return new WP_REST_Response( + array( + 'suggestions' => array(), + 'message' => 'Analysis complete but suggestions could not be parsed.', + ), + 200 + ); + } + + return new WP_REST_Response( + array( + 'suggestions' => $suggestions_json['suggestions'] ?? array(), + 'summary' => $suggestions_json['summary'] ?? 'Analysis complete.', + 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), ), 200 ); @@ -5711,15 +7039,349 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; public function handle_get_image_recommendations( $request ) { $post_id = $request->get_param( 'post_id' ); + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $images = $image_manager->get_image_recommendations( $post_id ); + // Block-level sync: ensure each unresolved image block has a stable + // agent id and a corresponding recommendation row. + if ( $post_id > 0 ) { + $post = get_post( $post_id ); + if ( $post instanceof WP_Post && ! empty( $post->post_content ) ) { + $post_config = $this->get_post_config( $post_id ); + if ( ! empty( $post_config['include_images'] ) ) { + $images = $this->sync_image_block_recommendations( $post_id, $post ); + } + } + } + return new WP_REST_Response( array( 'images' => $images ), 200 ); } + /** + * Ensure unresolved image blocks are mapped 1:1 to recommendation rows. + * + * @param int $post_id Post ID. + * @param WP_Post $post Post object. + * @return array + */ + private function sync_image_block_recommendations( $post_id, $post ) { + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); + $post_content = (string) $post->post_content; + $blocks = parse_blocks( $post_content ); + $changed = false; + $slots = array(); + $slot_index = 0; + $post_title = trim( wp_strip_all_tags( (string) $post->post_title ) ); + + $walk = function( &$items, $heading_context = '' ) use ( &$walk, &$changed, &$slots, &$slot_index, $post_id, $post_title ) { + foreach ( $items as &$block ) { + $name = $block['blockName'] ?? ''; + $attrs = $block['attrs'] ?? array(); + + if ( 'core/heading' === $name ) { + $heading = ''; + if ( ! empty( $attrs['content'] ) ) { + $heading = trim( wp_strip_all_tags( (string) $attrs['content'] ) ); + } elseif ( ! empty( $block['innerHTML'] ) ) { + $heading = trim( wp_strip_all_tags( (string) $block['innerHTML'] ) ); + } + if ( '' !== $heading ) { + $heading_context = $heading; + } + } + + if ( 'core/image' === $name ) { + $image_id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0; + if ( $image_id <= 0 ) { + $slot_index++; + $agent_id = isset( $attrs['data-agent-image-id'] ) ? trim( (string) $attrs['data-agent-image-id'] ) : ''; + if ( '' === $agent_id ) { + $agent_id = 'img_' . $post_id . '_blk_' . $slot_index . '_' . substr( wp_hash( microtime( true ) . wp_rand() ), 0, 8 ); + $attrs['data-agent-image-id'] = $agent_id; + $class_name = isset( $attrs['className'] ) ? (string) $attrs['className'] : ''; + if ( false === strpos( $class_name, 'wpaw-agent-img-' ) ) { + $attrs['className'] = trim( $class_name . ' wpaw-agent-img-' . $agent_id ); + } + $block['attrs'] = $attrs; + $changed = true; + } + + $slots[] = array( + 'agent_image_id' => $agent_id, + 'section_title' => '' !== $heading_context ? $heading_context : ( '' !== $post_title ? $post_title : 'Article Section' ), + 'slot_index' => $slot_index, + ); + } + } + + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { + $walk( $block['innerBlocks'], $heading_context ); + } + } + unset( $block ); + }; + + $walk( $blocks, '' ); + + if ( $changed ) { + $serialized = serialize_blocks( $blocks ); + if ( $serialized !== $post_content ) { + wp_update_post( + array( + 'ID' => $post_id, + 'post_content' => $serialized, + ) + ); + } + } + + $current_images = $image_manager->get_image_recommendations( $post_id ); + $by_agent_id = array(); + $existing_rows = array(); + if ( is_array( $current_images ) ) { + foreach ( $current_images as $row ) { + $key = isset( $row['agent_image_id'] ) ? (string) $row['agent_image_id'] : ''; + if ( '' !== $key ) { + $by_agent_id[ $key ] = true; + } + $existing_rows[] = $row; + } + } + + $slot_agent_ids = array(); + foreach ( $slots as $slot ) { + $slot_agent_ids[ $slot['agent_image_id'] ] = true; + } + + $orphan_rows = array(); + foreach ( $existing_rows as $row ) { + $key = isset( $row['agent_image_id'] ) ? (string) $row['agent_image_id'] : ''; + if ( '' !== $key && ! isset( $slot_agent_ids[ $key ] ) ) { + $orphan_rows[] = $row; + } + } + + $focus_variants = array( + 'establishing scene', + 'close-up detail', + 'human activity and impact', + 'before-and-after comparison', + 'infographic-like composition', + ); + + foreach ( $slots as $slot ) { + $agent_id = $slot['agent_image_id']; + if ( isset( $by_agent_id[ $agent_id ] ) ) { + continue; + } + + $focus = $focus_variants[ ( (int) $slot['slot_index'] - 1 ) % count( $focus_variants ) ]; + $section_title = $slot['section_title']; + $prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . '. Realistic editorial style, informative composition, natural lighting, high detail.'; + + if ( ! empty( $orphan_rows ) ) { + $orphan = array_shift( $orphan_rows ); + if ( isset( $orphan['id'] ) ) { + global $wpdb; + $table = $wpdb->prefix . 'wpaw_images'; + $wpdb->update( + $table, + array( + 'agent_image_id' => $agent_id, + 'placement' => 'slot_' . (int) $slot['slot_index'], + 'section_title' => $section_title, + 'prompt_initial' => $prompt, + 'alt_text_initial' => 'Gambar untuk bagian: ' . $section_title, + ), + array( + 'id' => (int) $orphan['id'], + 'post_id' => (int) $post_id, + ), + array( '%s', '%s', '%s', '%s', '%s' ), + array( '%d', '%d' ) + ); + $by_agent_id[ $agent_id ] = true; + continue; + } + } + + $image_manager->save_image_recommendation( + $post_id, + $agent_id, + 'slot_' . (int) $slot['slot_index'], + $section_title, + $prompt, + 'Gambar untuk bagian: ' . $section_title + ); + } + + $result = $image_manager->get_image_recommendations( $post_id ); + return is_array( $result ) ? $result : array(); + } + + /** + * Seed deterministic image recommendations from post content. + * + * @param int $post_id Post ID. + * @param string $post_title Post title. + * @param string $post_content Post content. + * @return bool True when at least one recommendation is saved. + */ + private function seed_basic_image_recommendations( $post_id, $post_title, $post_content ) { + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); + $existing = $image_manager->get_image_recommendations( $post_id ); + if ( is_array( $existing ) && ! empty( $existing ) ) { + return true; + } + + $max_images = 3; + $title = trim( wp_strip_all_tags( (string) $post_title ) ); + $seeded = 0; + + if ( '' !== $title ) { + $agent_image_id = 'img_' . $post_id . '_' . time() . '_hero'; + $image_manager->save_image_recommendation( + $post_id, + $agent_image_id, + 'hero', + $title, + 'Editorial hero image illustrating: ' . $title . '. Documentary style, natural lighting, high detail.', + 'Ilustrasi utama artikel: ' . $title + ); + $seeded++; + } + + $headings = array(); + if ( preg_match_all( '/]*>(.*?)<\/h[2-4]>/i', $post_content, $matches ) ) { + foreach ( $matches[1] as $heading ) { + $clean = trim( wp_strip_all_tags( $heading ) ); + if ( '' !== $clean ) { + $headings[] = $clean; + } + if ( count( $headings ) >= ( $max_images - 1 ) ) { + break; + } + } + } + + foreach ( $headings as $index => $heading ) { + $agent_image_id = 'img_' . $post_id . '_' . time() . '_sec_' . ( $index + 1 ); + $image_manager->save_image_recommendation( + $post_id, + $agent_image_id, + 'section_' . ( $index + 1 ), + $heading, + 'Contextual supporting image for section "' . $heading . '". Realistic scene, informative composition, editorial quality.', + 'Gambar pendukung untuk bagian: ' . $heading + ); + $seeded++; + } + + return $seeded > 0; + } + + /** + * Ensure recommendations exist for every unresolved image block. + * + * @param int $post_id Post ID. + * @param string $post_content Post content. + * @param string $post_title Post title. + * @return void + */ + private function ensure_recommendations_for_image_blocks( $post_id, $post_content, $post_title ) { + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); + $current_images = $image_manager->get_image_recommendations( $post_id ); + $current_count = is_array( $current_images ) ? count( $current_images ) : 0; + + $image_slots = $this->extract_unresolved_image_slots( $post_content ); + $target_count = count( $image_slots ); + if ( $target_count <= $current_count ) { + return; + } + + $fallback_title = trim( wp_strip_all_tags( (string) $post_title ) ); + for ( $i = $current_count; $i < $target_count; $i++ ) { + $slot_title = isset( $image_slots[ $i ]['section_title'] ) ? $image_slots[ $i ]['section_title'] : ''; + $section_title = '' !== $slot_title ? $slot_title : ( '' !== $fallback_title ? $fallback_title : 'Article Section' ); + $agent_image_id = 'img_' . $post_id . '_' . time() . '_slot_' . ( $i + 1 ); + $focus_variants = array( + 'establishing scene', + 'close-up detail', + 'human activity and impact', + 'before-and-after comparison', + 'infographic-like composition', + ); + $focus = $focus_variants[ $i % count( $focus_variants ) ]; + $prompt = 'Contextual image for section "' . $section_title . '" with focus on ' . $focus . '. Realistic editorial style, informative composition, natural lighting, high detail.'; + + $image_manager->save_image_recommendation( + $post_id, + $agent_image_id, + 'slot_' . ( $i + 1 ), + $section_title, + $prompt, + 'Gambar untuk bagian: ' . $section_title + ); + } + } + + /** + * Extract unresolved image slots with nearest heading context. + * + * @param string $post_content Post content. + * @return array + */ + private function extract_unresolved_image_slots( $post_content ) { + $slots = array(); + $blocks = parse_blocks( (string) $post_content ); + + $walk = function( $items, $heading_context = '' ) use ( &$walk, &$slots ) { + foreach ( $items as $block ) { + $name = $block['blockName'] ?? ''; + $attrs = $block['attrs'] ?? array(); + + if ( 'core/heading' === $name ) { + $heading = ''; + if ( ! empty( $attrs['content'] ) ) { + $heading = trim( wp_strip_all_tags( (string) $attrs['content'] ) ); + } elseif ( ! empty( $block['innerHTML'] ) ) { + $heading = trim( wp_strip_all_tags( (string) $block['innerHTML'] ) ); + } + if ( '' !== $heading ) { + $heading_context = $heading; + } + } + + if ( 'core/image' === $name ) { + $image_id = isset( $attrs['id'] ) ? (int) $attrs['id'] : 0; + if ( $image_id <= 0 ) { + $slots[] = array( + 'section_title' => $heading_context, + ); + } + } + + if ( ! empty( $block['innerBlocks'] ) && is_array( $block['innerBlocks'] ) ) { + $walk( $block['innerBlocks'], $heading_context ); + } + } + }; + + $walk( $blocks, '' ); + return $slots; + } + /** * Handle generate image request. * @@ -5732,6 +7394,14 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; $prompt = $request->get_param( 'prompt' ); $variant_count = $request->get_param( 'variant_count' ) ?? 2; + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $variants = $image_manager->generate_image_variants( $post_id, @@ -5762,6 +7432,14 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; $variant_id = $request->get_param( 'variant_id' ); $alt_text = $request->get_param( 'alt' ); + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + $image_manager = WP_Agentic_Writer_Image_Manager::get_instance(); $result = $image_manager->commit_image_variant( $post_id, @@ -5776,4 +7454,1291 @@ Respond with ONLY the intent code (e.g., \"create_outline\"). No explanation."; return new WP_REST_Response( $result, 200 ); } + + /** + * Handle multi-pass refinement request. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_refine_multi_pass( $request ) { + $params = $request->get_json_params(); + $pass = $params['pass'] ?? 'clarity'; + $blocks = $params['blocks'] ?? array(); + $focus_keyword = $params['focusKeyword'] ?? ''; + $post_id = $params['postId'] ?? 0; + + // Check post permission before using postId for cost tracking. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $pass_prompts = array( + 'clarity' => 'Improve the clarity, readability, and flow of this content. Make sentences clearer, remove ambiguity, and ensure smooth transitions between ideas.', + 'seo' => 'Optimize this content for SEO. Naturally incorporate the focus keyword "%s" where appropriate. Ensure good keyword density (1-2.5%), include variations of the keyword, and maintain readability.', + 'quality' => 'Enhance the overall quality of this content. Check for grammar, spelling, and punctuation errors. Improve sentence structure and word choice. Ensure consistent tone throughout.', + ); + + $prompt = $pass_prompts[$pass] ?? $pass_prompts['clarity']; + if ($pass === 'seo' && $focus_keyword) { + $prompt = sprintf($prompt, $focus_keyword); + } + + // Extract text from blocks + $content = ''; + foreach ($blocks as $block) { + $content .= $this->extract_block_content_from_attrs($block['name'] ?? 'core/paragraph', $block['attributes'] ?? array()) . "\n\n"; + } + + if (empty(trim($content))) { + return new WP_Error('empty_content', 'No content to refine', array('status' => 400)); + } + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task('refinement'); + $provider = $provider_result->provider; + $messages = array( + array( + 'role' => 'user', + 'content' => $prompt . "\n\nContent to refine:\n\n" . $content, + ), + ); + + $response = $provider->chat($messages, array(), 'refinement'); + + if (is_wp_error($response)) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model( 'refinement' ), + 'refine_multi_pass', + 0, + 0, + 0, + $provider_result, + '', + 'error' + ); + return $response; + } + + // Track cost. + $this->track_ai_cost( + $post_id, + $response['model'] ?? '', + 'refine_multi_pass', + $response['input_tokens'] ?? 0, + $response['output_tokens'] ?? 0, + $response['cost'] ?? 0, + $provider_result, + '', + 'success' + ); + + return new WP_REST_Response( + array( + 'pass' => $pass, + 'refined_content' => $response['content'] ?? '', + 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), + ), + 200 + ); + } + + /** + * Handle article-wide refinement request. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_refine_article( $request ) { + $params = $request->get_json_params(); + $instructions = $params['instructions'] ?? 'Improve overall quality'; + $blocks = $params['blocks'] ?? array(); + $post_id = $params['postId'] ?? 0; + + // Extract text from blocks + $content = ''; + $block_count = 0; + foreach ($blocks as $block) { + $block_content = $this->extract_block_content_from_attrs($block['name'] ?? 'core/paragraph', $block['attributes'] ?? array()); + if (!empty(trim($block_content))) { + $content .= "[Block " . ($block_count + 1) . "]\n" . $block_content . "\n\n"; + $block_count++; + } + } + + if (empty(trim($content))) { + return new WP_Error('empty_content', 'No content to refine', array('status' => 400)); + } + + // Check post permission if post_id is provided. + if ( $post_id > 0 && ! $this->check_post_permission( $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $prompt = "Review and improve the following article content based on these instructions: " . $instructions . "\n\n"; + $prompt .= "IMPORTANT: Return the improved content preserving all block structure using this exact format:\n"; + $prompt .= "- Start each block with [Block N] on its own line\n"; + $prompt .= "- Keep the same number of blocks as the original\n"; + $prompt .= "- Preserve any code blocks, lists, or formatting within each block\n\n"; + $prompt .= "Original content:\n\n" . $content; + + $provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task('refinement'); + $provider = $provider_result->provider; + $messages = array( + array( + 'role' => 'user', + 'content' => $prompt, + ), + ); + + $response = $provider->chat($messages, array(), 'refinement'); + + if (is_wp_error($response)) { + // Track failed attempt for observability. + $this->track_ai_cost( + $post_id, + WPAW_Model_Registry::get_default_model( 'refinement' ), + 'refine_article', + 0, + 0, + 0, + $provider_result, + '', + 'error' + ); + return $response; + } + + // Parse response back to blocks format + $refined_blocks = $this->parse_refined_blocks($response['content'] ?? '', $block_count); + + // Track cost. + $this->track_ai_cost( + $post_id, + $response['model'] ?? '', + 'refine_article', + $response['input_tokens'] ?? 0, + $response['output_tokens'] ?? 0, + $response['cost'] ?? 0, + $provider_result, + '', + 'success' + ); + + return new WP_REST_Response( + array( + 'blocks' => $refined_blocks, + 'count' => count($refined_blocks), + 'cost' => $response['cost'] ?? 0, + 'provider_metadata' => $this->build_provider_metadata( + $provider_result, + $response['model'] ?? '' + ), + ), + 200 + ); + } + + /** + * Parse refined blocks from AI response. + * + * @param string $content AI response content. + * @param int $expected_count Expected number of blocks. + * @return array Array of block contents. + */ + private function parse_refined_blocks( $content, $expected_count = 0 ) { + $blocks = array(); + + // Split by [Block N] markers + $parts = preg_split('/\[Block\s*\d+\]/i', $content); + + // First part is usually empty or intro text, skip it + array_shift($parts); + + foreach ($parts as $part) { + $block_content = trim($part); + if (!empty($block_content)) { + $blocks[] = $block_content; + } + } + + // If parsing didn't work well, return the whole content as single block + if (empty($blocks) && !empty(trim($content))) { + $blocks[] = trim($content); + } + + return $blocks; + } + + /** + * Handle GEO (Generative Engine Optimization) scoring request. + * + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error Response. + */ + public function handle_geo_score( $request ) { + $post_id = isset( $request['post_id'] ) ? (int) $request['post_id'] : 0; + if ( $post_id <= 0 ) { + return new WP_Error( 'invalid_post', 'Invalid post ID', array( 'status' => 400 ) ); + } + + $post = get_post( $post_id ); + if ( ! $post ) { + return new WP_Error( 'post_not_found', 'Post not found', array( 'status' => 404 ) ); + } + + $post_config = $this->get_post_config( $post_id ); + $content = wp_strip_all_tags( $post->post_content ); + $title = $post->post_title; + + $geo = array( + 'score' => 0, + 'max_score' => 100, + 'rating' => 'poor', + 'checks' => array(), + 'suggestions' => array(), + ); + + $total_checks = 0; + $total_score = 0; + + // Check 1: Directness - Does the content answer questions directly? + $total_checks++; + $directness_indicators = array( + 'this article', 'in this guide', 'in this post', 'here\'s how', 'here\'s what', + 'the best way', 'how to', 'step by step', 'in this tutorial', 'learn how' + ); + $directness_count = 0; + foreach ( $directness_indicators as $indicator ) { + $directness_count += substr_count( strtolower( $content ), $indicator ); + } + + if ( $directness_count >= 2 ) { + $geo['checks'][] = array( + 'name' => 'Directness', + 'status' => 'good', + 'message' => 'Content provides direct answers', + 'score' => 20, + ); + $total_score += 20; + } elseif ( $directness_count >= 1 ) { + $geo['checks'][] = array( + 'name' => 'Directness', + 'status' => 'ok', + 'message' => 'Some direct answers found, consider being more explicit', + 'score' => 12, + ); + $total_score += 12; + } else { + $geo['checks'][] = array( + 'name' => 'Directness', + 'status' => 'warning', + 'message' => 'Content may be too indirect. Add clear intro sentences that directly address the topic.', + 'score' => 5, + ); + $total_score += 5; + $geo['suggestions'][] = 'Start with a clear statement: "This guide explains how to [topic]" or "In this article, you\'ll learn [benefit]"'; + } + + // Check 2: Structure - Is the content well-organized with clear headings? + $total_checks++; + $heading_count = preg_match_all( '/]*>/i', $post->post_content, $matches ); + $paragraph_count = preg_match_all( '/]*>/i', $post->post_content, $matches ); + + if ( $heading_count >= 3 && $paragraph_count >= 5 ) { + $geo['checks'][] = array( + 'name' => 'Structure', + 'status' => 'good', + 'message' => "Excellent structure with {$heading_count} headings and {$paragraph_count} paragraphs", + 'score' => 20, + ); + $total_score += 20; + } elseif ( $heading_count >= 1 ) { + $geo['checks'][] = array( + 'name' => 'Structure', + 'status' => 'ok', + 'message' => 'Basic structure present, consider adding more subheadings', + 'score' => 12, + ); + $total_score += 12; + } else { + $geo['checks'][] = array( + 'name' => 'Structure', + 'status' => 'warning', + 'message' => 'Content lacks structure. Add clear H2/H3 headings to break up content.', + 'score' => 5, + ); + $total_score += 5; + $geo['suggestions'][] = 'Add H2 headings every 200-300 words to organize content into scannable sections'; + } + + // Check 3: Authority - Does the content demonstrate expertise? + $total_checks++; + $authority_indicators = array( + 'experience', 'years', 'research', 'study', 'according to', 'expert', + 'professional', 'certified', 'proven', 'tested', 'verified' + ); + $authority_count = 0; + foreach ( $authority_indicators as $indicator ) { + $authority_count += substr_count( strtolower( $content ), $indicator ); + } + + if ( $authority_count >= 3 ) { + $geo['checks'][] = array( + 'name' => 'Authority', + 'status' => 'good', + 'message' => 'Content demonstrates strong expertise', + 'score' => 20, + ); + $total_score += 20; + } elseif ( $authority_count >= 1 ) { + $geo['checks'][] = array( + 'name' => 'Authority', + 'status' => 'ok', + 'message' => 'Some authority signals present', + 'score' => 12, + ); + $total_score += 12; + } else { + $geo['checks'][] = array( + 'name' => 'Authority', + 'status' => 'warning', + 'message' => 'Content lacks authority signals. Add experience, research, or expert references.', + 'score' => 5, + ); + $total_score += 5; + $geo['suggestions'][] = 'Add phrases like "Based on years of experience", "Research shows", or "Experts recommend"'; + } + + // Check 4: Clarity - Is the content easy to understand? + $total_checks++; + $word_count = str_word_count( $content ); + $sentence_count = preg_match_all( '/[.!?]+/', $content ); + $avg_sentence_length = $sentence_count > 0 ? $word_count / $sentence_count : 0; + + // Count complex words (7+ characters) + $words = preg_split( '/\s+/', $content ); + $complex_words = 0; + foreach ( $words as $word ) { + $clean_word = preg_replace( '/[^a-zA-Z]/', '', $word ); + if ( strlen( $clean_word ) >= 7 ) { + $complex_words++; + } + } + $flesch_score = $word_count > 0 ? 206.835 - (1.015 * ($word_count / max( 1, $sentence_count ))) - (84.6 * ($complex_words / $word_count)) : 0; + $readability = $flesch_score >= 60 ? 'good' : ( $flesch_score >= 40 ? 'ok' : 'complex' ); + + if ( $readability === 'good' ) { + $geo['checks'][] = array( + 'name' => 'Clarity', + 'status' => 'good', + 'message' => sprintf( 'Excellent readability (Flesch: %.0f)', $flesch_score ), + 'score' => 20, + ); + $total_score += 20; + } elseif ( $readability === 'ok' ) { + $geo['checks'][] = array( + 'name' => 'Clarity', + 'status' => 'ok', + 'message' => sprintf( 'Average readability (Flesch: %.0f)', $flesch_score ), + 'score' => 12, + ); + $total_score += 12; + } else { + $geo['checks'][] = array( + 'name' => 'Clarity', + 'status' => 'warning', + 'message' => sprintf( 'Complex text (Flesch: %.0f). Consider shorter sentences.', $flesch_score ), + 'score' => 5, + ); + $total_score += 5; + $geo['suggestions'][] = 'Break long sentences into shorter ones. Aim for 15-20 words per sentence average.'; + } + + // Check 5: Completeness - Does the content cover the topic thoroughly? + $total_checks++; + $focus_keyword = $post_config['seo_focus_keyword'] ?? ''; + + if ( ! empty( $focus_keyword ) ) { + $keyword_in_intro = stripos( substr( $content, 0, 200 ), $focus_keyword ) !== false; + $keyword_in_conclusion = stripos( substr( $content, -200 ), $focus_keyword ) !== false; + $keyword_count = substr_count( strtolower( $content ), strtolower( $focus_keyword ) ); + $keyword_density = $word_count > 0 ? ($keyword_count / $word_count) * 100 : 0; + + if ( $keyword_in_intro && $keyword_in_conclusion && $keyword_density >= 0.5 ) { + $geo['checks'][] = array( + 'name' => 'Completeness', + 'status' => 'good', + 'message' => 'Topic covered comprehensively with keyword in intro and conclusion', + 'score' => 20, + ); + $total_score += 20; + } elseif ( $keyword_density >= 0.5 ) { + $geo['checks'][] = array( + 'name' => 'Completeness', + 'status' => 'ok', + 'message' => 'Topic covered but improve keyword placement', + 'score' => 12, + ); + $total_score += 12; + } else { + $geo['checks'][] = array( + 'name' => 'Completeness', + 'status' => 'warning', + 'message' => 'Topic may not be fully covered. Ensure keyword appears in intro, body, and conclusion.', + 'score' => 5, + ); + $total_score += 5; + $geo['suggestions'][] = 'Include focus keyword in your introduction and conclusion paragraph'; + } + } else { + $geo['checks'][] = array( + 'name' => 'Completeness', + 'status' => 'ok', + 'message' => 'Focus keyword not set - cannot fully assess completeness', + 'score' => 10, + ); + $total_score += 10; + $geo['suggestions'][] = 'Set a focus keyword to enable full GEO analysis'; + } + + // Calculate final score + $geo['score'] = $total_score; + + // Determine rating + if ( $geo['score'] >= 80 ) { + $geo['rating'] = 'excellent'; + } elseif ( $geo['score'] >= 60 ) { + $geo['rating'] = 'good'; + } elseif ( $geo['score'] >= 40 ) { + $geo['rating'] = 'fair'; + } else { + $geo['rating'] = 'poor'; + } + + // Add AI Overview eligibility note + $geo['ai_overview_eligible'] = $geo['score'] >= 80; + + return new WP_REST_Response( $geo, 200 ); + } + + /** + * Handle generate title request. + * + * Uses WordPress 7.0 AI Client when available, falls back to legacy. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_generate_title( $request ) { + $params = $request->get_json_params(); + $content = sanitize_textarea_field( $params['content'] ?? '' ); + + if ( empty( $content ) ) { + return new WP_Error( + 'missing_content', + __( 'Content is required for title generation.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + $options = array( + 'post_id' => isset( $params['post_id'] ) ? (int) $params['post_id'] : 0, + ); + + $client = WPAW_WP_AI_Client::get_instance(); + $result = $client->generate_title( $content, $options ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( + array( + 'title' => $result, + 'source' => $client->get_ai_mode(), + ), + 200 + ); + } + + /** + * Handle generate excerpt request. + * + * Uses WordPress 7.0 AI Client when available, falls back to legacy. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_generate_excerpt( $request ) { + $params = $request->get_json_params(); + $content = sanitize_textarea_field( $params['content'] ?? '' ); + + if ( empty( $content ) ) { + return new WP_Error( + 'missing_content', + __( 'Content is required for excerpt generation.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + $options = array( + 'post_id' => isset( $params['post_id'] ) ? (int) $params['post_id'] : 0, + ); + + $client = WPAW_WP_AI_Client::get_instance(); + $result = $client->generate_excerpt( $content, $options ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return new WP_REST_Response( + array( + 'excerpt' => $result, + 'source' => $client->get_ai_mode(), + ), + 200 + ); + } + + /** + * Handle get AI capabilities request. + * + * Returns the current AI capabilities based on available providers. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response + */ + public function handle_get_ai_capabilities( $request ) { + $client = WPAW_WP_AI_Client::get_instance(); + $capabilities = $client->get_capabilities(); + + return new WP_REST_Response( $capabilities, 200 ); + } + + /** + * Handle search request for research. + * + * Uses Brave Search API for web search results. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_search( $request ) { + $params = $request->get_json_params(); + $query = sanitize_text_field( $params['query'] ?? '' ); + $count = isset( $params['count'] ) ? absint( $params['count'] ) : 5; + + if ( empty( $query ) ) { + return new WP_Error( + 'missing_query', + __( 'Search query is required.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); + $results = $brave->search( $query, $count ); + + if ( is_wp_error( $results ) ) { + return $results; + } + + return new WP_REST_Response( + array( + 'query' => $query, + 'results' => $results, + 'count' => count( $results ), + ), + 200 + ); + } + + /** + * Handle fetch content request for research. + * + * Fetches and extracts content from a URL for AI context. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_fetch_content( $request ) { + $params = $request->get_json_params(); + $url = esc_url_raw( $params['url'] ?? '' ); + + if ( empty( $url ) ) { + return new WP_Error( + 'missing_url', + __( 'URL is required.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Validate URL format + if ( ! wp_http_validate_url( $url ) ) { + return new WP_Error( + 'invalid_url', + __( 'Invalid URL provided.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Fetch the content + $response = wp_remote_get( + $url, + array( + 'timeout' => 20, + 'user-agent' => 'Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)', + ) + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $http_code = wp_remote_retrieve_response_code( $response ); + if ( $http_code !== 200 ) { + return new WP_Error( + 'fetch_failed', + sprintf( __( 'Failed to fetch URL (HTTP %d).', 'wp-agentic-writer' ), $http_code ), + array( 'status' => $http_code ) + ); + } + + $body = wp_remote_retrieve_body( $response ); + + // Strip HTML tags and get clean text + $content = wp_strip_all_tags( $body ); + + // Truncate to prevent token overflow (max ~4000 chars for context) + if ( strlen( $content ) > 4000 ) { + $content = substr( $content, 0, 4000 ) . '...'; + } + + return new WP_REST_Response( + array( + 'url' => $url, + 'content' => $content, + 'length' => strlen( $content ), + ), + 200 + ); + } + + /** + * Handle research summary request. + * + * Performs multiple searches and generates a research summary. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_research_summary( $request ) { + $params = $request->get_json_params(); + $topic = sanitize_text_field( $params['topic'] ?? '' ); + $depth = sanitize_text_field( $params['depth'] ?? 'basic' ); + $include_urls = isset( $params['include_urls'] ) ? (bool) $params['include_urls'] : false; + + if ( empty( $topic ) ) { + return new WP_Error( + 'missing_topic', + __( 'Research topic is required.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Determine search count based on depth + $search_counts = array( + 'basic' => 3, + 'medium' => 5, + 'deep' => 8, + ); + $count = $search_counts[ $depth ] ?? 3; + + $brave = WP_Agentic_Writer_Brave_Search_API::get_instance(); + + // Perform main search + $main_results = $brave->search( $topic, $count ); + + if ( is_wp_error( $main_results ) ) { + return $main_results; + } + + $research_data = array( + 'topic' => $topic, + 'depth' => $depth, + 'search_results' => $main_results, + 'formatted_context' => $brave->format_results_for_llm( $main_results, $topic ), + ); + + // Optionally fetch content from top URLs + if ( $include_urls && ! empty( $main_results ) ) { + $fetched_content = array(); + $max_urls = min( 2, count( $main_results ) ); // Limit to 2 URLs + + for ( $i = 0; $i < $max_urls; $i++ ) { + $url = $main_results[ $i ]['url'] ?? ''; + if ( empty( $url ) ) { + continue; + } + + $fetch_response = wp_remote_get( + $url, + array( + 'timeout' => 15, + 'user-agent' => 'Mozilla/5.0 (compatible; WP-Agentic-Writer/1.0)', + ) + ); + + if ( ! is_wp_error( $fetch_response ) && 200 === wp_remote_retrieve_response_code( $fetch_response ) ) { + $body = wp_remote_retrieve_body( $fetch_response ); + $content = wp_strip_all_tags( $body ); + + if ( strlen( $content ) > 2000 ) { + $content = substr( $content, 0, 2000 ) . '...'; + } + + $fetched_content[] = array( + 'title' => $main_results[ $i ]['title'], + 'url' => $url, + 'excerpt' => $content, + ); + } + } + + $research_data['fetched_content'] = $fetched_content; + } + + return new WP_REST_Response( $research_data, 200 ); + } + + /** + * Handle get conversations list request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_conversations( $request ) { + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + $status = sanitize_text_field( $request->get_param( 'status' ) ?: 'active' ); + $limit = (int) $request->get_param( 'limit' ) ?: 20; + $post_id = (int) $request->get_param( 'post_id' ) ?: 0; + + // If post_id is specified, check authorization before returning session. + if ( $post_id > 0 ) { + // Authorization: User must be able to edit this post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $sessions = $manager->get_sessions_for_post( $post_id ); + return new WP_REST_Response( + array( + 'sessions' => $sessions, + 'count' => count( $sessions ), + ), + 200 + ); + } + + if ( $request->get_param( 'uncompleted' ) ) { + $sessions = $manager->get_uncompleted_sessions( $limit ); + } else { + $sessions = $manager->get_user_sessions( $status, $limit ); + } + + return new WP_REST_Response( + array( + 'sessions' => $sessions, + 'count' => count( $sessions ), + ), + 200 + ); + } + + /** + * Handle create conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_create_conversation( $request ) { + $params = $request->get_json_params(); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + $post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0; + $focus_keyword = isset( $params['focus_keyword'] ) ? sanitize_text_field( $params['focus_keyword'] ) : ''; + $title = isset( $params['title'] ) ? sanitize_text_field( $params['title'] ) : ''; + + // Authorization: If linking to a post, check edit permission. + if ( $post_id > 0 && ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to create a session for this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + if ( '' === $title && $post_id > 0 ) { + $post = get_post( $post_id ); + $base_title = $post ? sanitize_text_field( $post->post_title ) : ''; + if ( '' === $base_title ) { + $base_title = 'Conversation'; + } + $title = sprintf( '%s - %s', $base_title, current_time( 'Y-m-d H:i' ) ); + } + + $session_id = $manager->create_session( array( + 'post_id' => $post_id, + 'focus_keyword' => $focus_keyword, + 'title' => $title, + ) ); + + if ( is_wp_error( $session_id ) ) { + return $session_id; + } + + $session = $manager->get_session( $session_id ); + + return new WP_REST_Response( $session, 201 ); + } + + /** + * Handle get single conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_conversation( $request ) { + $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if ( ! $manager->current_user_can_access( $session_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to access this conversation.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $session = $manager->get_session( $session_id ); + + if ( ! $session ) { + return new WP_Error( + 'not_found', + __( 'Conversation not found.', 'wp-agentic-writer' ), + array( 'status' => 404 ) + ); + } + + return new WP_REST_Response( $session, 200 ); + } + + /** + * Handle update conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_update_conversation( $request ) { + $params = $request->get_json_params(); + $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if ( ! $manager->current_user_can_access( $session_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to modify this conversation.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $session = $manager->get_session( $session_id ); + + if ( ! $session ) { + return new WP_Error( + 'not_found', + __( 'Conversation not found.', 'wp-agentic-writer' ), + array( 'status' => 404 ) + ); + } + + // Update fields + if ( isset( $params['title'] ) ) { + $manager->update_title( $session_id, $params['title'] ); + } + + if ( isset( $params['focus_keyword'] ) ) { + $manager->update_focus_keyword( $session_id, $params['focus_keyword'] ); + } + + if ( isset( $params['status'] ) ) { + if ( $params['status'] === 'completed' ) { + $manager->mark_completed( $session_id ); + } + } + + $updated_session = $manager->get_session( $session_id ); + + return new WP_REST_Response( $updated_session, 200 ); + } + + /** + * Handle delete conversation request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_delete_conversation( $request ) { + $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if ( ! $manager->current_user_can_access( $session_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to delete this conversation.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $result = $manager->delete_session( $session_id ); + + if ( ! $result ) { + return new WP_Error( + 'delete_failed', + __( 'Failed to delete conversation.', 'wp-agentic-writer' ), + array( 'status' => 500 ) + ); + } + + return new WP_REST_Response( + array( 'deleted' => true ), + 200 + ); + } + + /** + * Handle update conversation messages request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_update_conversation_messages( $request ) { + $params = $request->get_json_params(); + $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Check authorization + if ( ! $manager->current_user_can_access( $session_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have permission to modify this conversation.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $session = $manager->get_session( $session_id ); + + if ( ! $session ) { + return new WP_Error( + 'not_found', + __( 'Conversation not found.', 'wp-agentic-writer' ), + array( 'status' => 404 ) + ); + } + + $messages = isset( $params['messages'] ) ? $params['messages'] : array(); + + if ( ! is_array( $messages ) ) { + return new WP_Error( + 'invalid_messages', + __( 'Messages must be an array.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + $manager->update_messages( $session_id, $messages ); + + return new WP_REST_Response( + array( 'updated' => true, 'message_count' => count( $messages ) ), + 200 + ); + } + + /** + * Handle link conversation to post request. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_link_conversation_to_post( $request ) { + $params = $request->get_json_params(); + $session_id = sanitize_text_field( $request->get_param( 'session_id' ) ); + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // First verify user has access to this session (before linking to post). + if ( ! $manager->current_user_can_access( $session_id ) ) { + return new WP_Error( + 'forbidden', + __( 'You do not have access to this conversation.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $session = $manager->get_session( $session_id ); + + if ( ! $session ) { + return new WP_Error( + 'not_found', + __( 'Conversation not found.', 'wp-agentic-writer' ), + array( 'status' => 404 ) + ); + } + + $post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0; + + if ( $post_id <= 0 ) { + return new WP_Error( + 'invalid_post', + __( 'Valid post ID is required.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Verify post exists and user can edit + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'permission_denied', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + $manager->link_to_post( $session_id, $post_id ); + + $updated_session = $manager->get_session( $session_id ); + + return new WP_REST_Response( + array( + 'linked' => true, + 'post_id' => $post_id, + 'session' => $updated_session, + ), + 200 + ); + } + + /** + * Handle migrate chat history request. + * + * Migrates legacy _wpaw_chat_history from post meta to session table. + * + * @since 0.1.4 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_migrate_chat_history( $request ) { + $post_id = (int) $request->get_param( 'post_id' ); + + if ( $post_id <= 0 ) { + return new WP_Error( + 'invalid_post', + __( 'Valid post ID is required.', 'wp-agentic-writer' ), + array( 'status' => 400 ) + ); + } + + // Verify post exists and user can edit + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'permission_denied', + __( 'You do not have permission to edit this post.', 'wp-agentic-writer' ), + array( 'status' => 403 ) + ); + } + + // Use Context Service for migration + $context_service = WP_Agentic_Writer_Context_Service::get_instance(); + $result = $context_service->migrate_legacy_chat_history( $post_id ); + + if ( ! $result ) { + return new WP_Error( + 'migration_failed', + __( 'Failed to migrate chat history.', 'wp-agentic-writer' ), + array( 'status' => 500 ) + ); + } + + // Return migration status + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + $sessions = $manager->get_sessions_for_post( $post_id ); + + return new WP_REST_Response( + array( + 'migrated' => true, + 'post_id' => $post_id, + 'sessions_count' => count( $sessions ), + 'message' => 'Legacy chat history has been migrated to session table.', + ), + 200 + ); + } + + /** + * Auto-save post and link conversation when writing execution begins. + * + * @since 0.1.4 + * @param string $session_id Session ID. + * @param int $post_id Current post ID (can be 0). + * @return int New post ID if saved, or original if not needed. + */ + public function ensure_conversation_linked_to_post( $session_id, $post_id = 0 ) { + $manager = WP_Agentic_Writer_Conversation_Manager::get_instance(); + + // Already linked + if ( $post_id > 0 ) { + return $post_id; + } + + // Check if editor has content + if ( ! $manager->post_has_content( get_the_ID() ) ) { + // No content yet, keep as post_id = 0 + return 0; + } + + // Get current post (auto-save with placeholder title) + $current_post_id = get_the_ID(); + if ( $current_post_id && $current_post_id > 0 ) { + // Update post with placeholder title if needed + $post = get_post( $current_post_id ); + if ( $post && empty( $post->post_title ) ) { + wp_update_post( array( + 'ID' => $current_post_id, + 'post_title' => 'Draft - ' . date( 'Y-m-d H:i' ), + ) ); + } + + // Link conversation to post + $manager->link_to_post( $session_id, $current_post_id ); + + return $current_post_id; + } + + return 0; + } + + /** + * Get user preferences (per-user settings). + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_get_user_preferences( $request ) { + $user_id = get_current_user_id(); + + // Return defaults if not logged in + if ( $user_id === 0 ) { + return new WP_REST_Response( + array( + 'proactive_suggestions' => true, + 'command_palette_enabled' => true, + 'outline_panel_enabled' => true, + 'auto_save_interval' => 30, + 'preferred_model' => '', + 'preferred_language' => 'auto', + 'theme' => 'dark', + ), + 200 + ); + } + + $preferences = get_user_meta( $user_id, 'wpaw_user_preferences', true ); + + // Merge with defaults + $defaults = array( + 'proactive_suggestions' => true, + 'command_palette_enabled' => true, + 'outline_panel_enabled' => true, + 'auto_save_interval' => 30, + 'preferred_model' => '', + 'preferred_language' => 'auto', + 'theme' => 'dark', + ); + + $preferences = is_array( $preferences ) ? array_merge( $defaults, $preferences ) : $defaults; + + return new WP_REST_Response( $preferences, 200 ); + } + + /** + * Save user preferences (per-user settings). + * + * @since 0.2.0 + * @param WP_REST_Request $request REST request. + * @return WP_REST_Response|WP_Error + */ + public function handle_save_user_preferences( $request ) { + $user_id = get_current_user_id(); + + if ( $user_id === 0 ) { + return new WP_Error( + 'unauthorized', + __( 'You must be logged in to save preferences.', 'wp-agentic-writer' ), + array( 'status' => 401 ) + ); + } + + $preferences = $request->get_json_params(); + + // Validate and sanitize + $sanitized = array( + 'proactive_suggestions' => ! empty( $preferences['proactive_suggestions'] ), + 'command_palette_enabled' => ! empty( $preferences['command_palette_enabled'] ), + 'outline_panel_enabled' => ! empty( $preferences['outline_panel_enabled'] ), + 'auto_save_interval' => max( 5, min( 300, (int) ( $preferences['auto_save_interval'] ?? 30 ) ) ), + 'preferred_model' => sanitize_text_field( $preferences['preferred_model'] ?? '' ), + 'preferred_language' => sanitize_text_field( $preferences['preferred_language'] ?? 'auto' ), + 'theme' => in_array( $preferences['theme'] ?? 'dark', array( 'dark', 'light' ), true ) ? $preferences['theme'] : 'dark', + ); + + update_user_meta( $user_id, 'wpaw_user_preferences', $sanitized ); + + return new WP_REST_Response( $sanitized, 200 ); + } }