/** * WP Agentic Writer - Gutenberg Sidebar * * @package WP_Agentic_Writer */ (function (wp) { const { registerPlugin } = wp.plugins; const { PluginSidebar } = wp.editPost; const { Panel, TextareaControl, TextControl, CheckboxControl, Button, Spinner } = wp.components; const { dispatch, select } = wp.data; const { RawHTML } = wp.element; // Sidebar Component. const AgenticWriterSidebar = ({ postId }) => { // Get settings from wpAgenticWriter global. const settings = typeof wpAgenticWriter !== 'undefined' ? wpAgenticWriter.settings : {}; // Tab state const [activeTab, setActiveTab] = React.useState('chat'); // Chat state const [messages, setMessages] = React.useState([]); const [input, setInput] = React.useState(''); const [isLoading, setIsLoading] = React.useState(false); const [agentMode, setAgentMode] = React.useState(() => { try { return window.localStorage.getItem('wpawAgentMode') || 'chat'; } catch (error) { return 'chat'; } }); // Config state const defaultPostConfig = React.useMemo(() => ({ article_length: 'medium', language: 'auto', tone: '', audience: '', experience_level: 'general', include_images: true, web_search: Boolean(settings.web_search_enabled), default_mode: 'writing', // SEO fields seo_focus_keyword: '', seo_secondary_keywords: '', seo_meta_description: '', seo_enabled: true, }), [settings.web_search_enabled]); const [postConfig, setPostConfig] = React.useState(defaultPostConfig); const [isConfigLoading, setIsConfigLoading] = React.useState(false); const [isConfigSaving, setIsConfigSaving] = React.useState(false); const [configError, setConfigError] = React.useState(''); const configHydratedRef = React.useRef(false); const lastSavedConfigRef = React.useRef(''); const configSaveTimeoutRef = React.useRef(null); const appliedDefaultModeRef = React.useRef(false); // Cost state const [cost, setCost] = React.useState({ session: 0, today: 0, monthlyUsed: 0 }); const [monthlyBudget, setMonthlyBudget] = React.useState(settings.monthly_budget || 600); const [isEditorLocked, setIsEditorLocked] = React.useState(false); // SEO audit state const [seoAudit, setSeoAudit] = React.useState(null); const [isSeoAuditing, setIsSeoAuditing] = React.useState(false); // Clarification state. const [inClarification, setInClarification] = React.useState(false); const [questions, setQuestions] = React.useState([]); const [currentQuestionIndex, setCurrentQuestionIndex] = React.useState(0); const [answers, setAnswers] = React.useState([]); const [detectedLanguage, setDetectedLanguage] = React.useState('english'); const [clarificationMode, setClarificationMode] = React.useState('generation'); const [pendingRefinement, setPendingRefinement] = React.useState(null); const [pendingEditPlan, setPendingEditPlan] = React.useState(null); const lastGenerationRequestRef = React.useRef(null); const currentPlanRef = React.useRef(null); const lastExecuteRequestRef = React.useRef(null); const sectionInsertIndexRef = React.useRef({}); const activeSectionIdRef = React.useRef(null); const sectionBlocksRef = React.useRef({}); const blockSectionRef = React.useRef({}); const markdownRendererRef = React.useRef(null); const lastRefineRequestRef = React.useRef(null); const lastChatRequestRef = React.useRef(null); // Mention autocomplete state const [showMentionAutocomplete, setShowMentionAutocomplete] = React.useState(false); const [mentionQuery, setMentionQuery] = React.useState(''); const [mentionOptions, setMentionOptions] = React.useState([]); const [mentionCursorIndex, setMentionCursorIndex] = React.useState(0); const [showSlashAutocomplete, setShowSlashAutocomplete] = React.useState(false); const [slashQuery, setSlashQuery] = React.useState(''); const [slashOptions, setSlashOptions] = React.useState([]); const [slashCursorIndex, setSlashCursorIndex] = React.useState(0); const inputRef = React.useRef(null); const streamTargetRef = React.useRef(null); // Undo stack for AI operations const [aiUndoStack, setAiUndoStack] = React.useState([]); const MAX_UNDO_STACK = 10; React.useEffect(() => { try { window.localStorage.setItem('wpawAgentMode', agentMode); } catch (error) { // Ignore storage errors in restricted environments. } }, [agentMode]); React.useEffect(() => { if (!postId) { return; } appliedDefaultModeRef.current = false; setIsConfigLoading(true); fetch(`${wpAgenticWriter.apiUrl}/post-config/${postId}`, { headers: { 'X-WP-Nonce': wpAgenticWriter.nonce, }, }) .then((response) => response.ok ? response.json() : Promise.reject(response)) .then((data) => { const merged = { ...defaultPostConfig, ...data }; setPostConfig(merged); lastSavedConfigRef.current = JSON.stringify(merged); configHydratedRef.current = true; if (merged.default_mode && !appliedDefaultModeRef.current) { setAgentMode(merged.default_mode); appliedDefaultModeRef.current = true; } }) .catch(() => { configHydratedRef.current = true; }) .finally(() => { setIsConfigLoading(false); }); }, [postId, defaultPostConfig]); const savePostConfig = React.useCallback(async (config) => { if (!postId) { return; } setIsConfigSaving(true); setConfigError(''); try { const response = await fetch(`${wpAgenticWriter.apiUrl}/post-config/${postId}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ postConfig: config }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to save post configuration'); } const data = await response.json(); lastSavedConfigRef.current = JSON.stringify(data); // Don't update state if data matches current - prevents focus loss setPostConfig((prev) => { const newConfig = { ...prev, ...data }; if (JSON.stringify(prev) === JSON.stringify(newConfig)) { return prev; // Return same reference to prevent re-render } return newConfig; }); } catch (error) { setConfigError(error.message || 'Failed to save post configuration'); } finally { setIsConfigSaving(false); } }, [postId]); React.useEffect(() => { if (!configHydratedRef.current || isConfigLoading) { return; } const serialized = JSON.stringify(postConfig); if (serialized === lastSavedConfigRef.current) { return; } if (configSaveTimeoutRef.current) { clearTimeout(configSaveTimeoutRef.current); } configSaveTimeoutRef.current = setTimeout(() => { savePostConfig(postConfig); }, 600); return () => { if (configSaveTimeoutRef.current) { clearTimeout(configSaveTimeoutRef.current); } }; }, [postConfig, isConfigLoading, savePostConfig]); React.useEffect(() => { if (!settings.cost_tracking_enabled || !postId) { return; } fetch(`${wpAgenticWriter.apiUrl}/cost-tracking/${postId}`, { headers: { 'X-WP-Nonce': wpAgenticWriter.nonce, }, }) .then((response) => response.json()) .then((data) => { if (data && typeof data.session === 'number') { setCost({ session: data.session, today: data.today?.total?.cost || 0, monthlyUsed: data.monthly?.used || 0, }); } if (data?.monthly?.budget) { setMonthlyBudget(data.monthly.budget); } }) .catch(() => { }); }, [postId]); // Chat messages container ref for auto-scroll const messagesEndRef = React.useRef(null); const messagesContainerRef = React.useRef(null); // Auto-scroll to bottom when messages change React.useEffect(() => { if (messagesEndRef.current) { messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); } }, [messages]); const progressRegex = /^(I'll|Writing|Now|Creating|Adding|Let me|I'll write|Saya|Saya akan|Sedang menulis|Sedang membuat|Menulis tentang|Membuat tentang|Thinking|Analyzing|Reviewing|Refining|Checking|Updating|Planning|Searching|Querying|Generated|Drafting|Reading|Context|Processing)/i; const activeTimelineStatuses = new Set([ 'active', 'starting', 'refining', 'checking', 'waiting', 'planning', 'plan_complete', 'writing', 'writing_section', ]); const writingTimelineStatuses = new Set(['writing', 'writing_section']); const findLastActiveTimelineIndex = (items) => { for (let i = items.length - 1; i >= 0; i--) { if (items[i].type === 'timeline' && activeTimelineStatuses.has(items[i].status)) { return i; } } return -1; }; const deactivateActiveTimelineEntries = (items) => { return items.map((item) => { if (item.type === 'timeline' && activeTimelineStatuses.has(item.status)) { return { ...item, status: 'inactive', }; } return item; }); }; const updateOrCreateTimelineEntry = (message) => { setMessages(prev => { const newMessages = [...prev]; const timelineIndex = findLastActiveTimelineIndex(newMessages); if (timelineIndex === -1) { newMessages.push({ role: 'system', type: 'timeline', status: 'active', message: message, timestamp: new Date() }); } else { newMessages[timelineIndex] = { ...newMessages[timelineIndex], message: message }; } return newMessages; }); }; // Undo helper functions const captureEditorSnapshot = (label = 'AI Operation') => { const allBlocks = select('core/block-editor').getBlocks(); const serializedBlocks = allBlocks.map((block) => wp.blocks.serialize(block)).join('\n'); return { label, timestamp: new Date(), blocks: serializedBlocks, }; }; const pushUndoSnapshot = (label = 'AI Operation') => { const snapshot = captureEditorSnapshot(label); setAiUndoStack((prev) => { const newStack = [...prev, snapshot]; if (newStack.length > MAX_UNDO_STACK) { return newStack.slice(-MAX_UNDO_STACK); } return newStack; }); }; const undoLastAiOperation = () => { if (aiUndoStack.length === 0) { return; } const lastSnapshot = aiUndoStack[aiUndoStack.length - 1]; const { resetBlocks } = dispatch('core/block-editor'); try { const parsedBlocks = wp.blocks.parse(lastSnapshot.blocks); resetBlocks(parsedBlocks); setAiUndoStack((prev) => prev.slice(0, -1)); setMessages((prev) => [...prev, { role: 'system', type: 'timeline', status: 'complete', message: `Undid: ${lastSnapshot.label}`, timestamp: new Date(), }]); } catch (error) { console.error('Failed to undo AI operation:', error); setMessages((prev) => [...prev, { role: 'system', type: 'error', content: 'Failed to undo operation: ' + error.message, }]); } }; React.useEffect(() => { const lastTimelineIndex = findLastActiveTimelineIndex(messages); const lastTimeline = lastTimelineIndex !== -1 ? messages[lastTimelineIndex] : null; const isWritingActive = Boolean( isLoading && lastTimeline && writingTimelineStatuses.has(lastTimeline.status) ); if (isWritingActive && !isEditorLocked) { dispatch('core/editor').lockPostSaving('wpaw-writing'); document.body.classList.add('wpaw-editor-locked'); setIsEditorLocked(true); } else if (!isWritingActive && isEditorLocked) { dispatch('core/editor').unlockPostSaving('wpaw-writing'); document.body.classList.remove('wpaw-editor-locked'); setIsEditorLocked(false); } }, [messages, isLoading, isEditorLocked]); const toTextValue = (value) => { if (value === null || value === undefined) { return ''; } if (typeof value === 'string' || typeof value === 'number') { return String(value); } return ''; }; const updatePostConfig = (key, value) => { setPostConfig((prev) => ({ ...prev, [key]: value })); }; // Run SEO Audit const runSeoAudit = async () => { if (isSeoAuditing || !postId) return; setIsSeoAuditing(true); try { const response = await fetch(`${wpAgenticWriter.apiUrl}/seo-audit/${postId}`, { headers: { 'X-WP-Nonce': wpAgenticWriter.nonce, }, }); const data = await response.json(); if (!response.ok) { throw new Error(data.message || 'Failed to run SEO audit'); } setSeoAudit(data); } catch (error) { console.error('SEO Audit error:', error); setMessages((prev) => [...prev, { role: 'assistant', content: `SEO Audit error: ${error.message}`, type: 'error', }]); } finally { setIsSeoAuditing(false); } }; // Generate meta description using AI const [isGeneratingMeta, setIsGeneratingMeta] = wp.element.useState(false); const generateMetaDescription = async () => { if (isGeneratingMeta) return; setIsGeneratingMeta(true); try { const response = await fetch(`${wpAgenticWriter.apiUrl}/generate-meta`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ postId: postId, focusKeyword: postConfig.seo_focus_keyword, }), }); if (!response.ok) { const data = await response.json(); throw new Error(data.message || 'Failed to generate meta description'); } const data = await response.json(); if (data.meta_description) { updatePostConfig('seo_meta_description', data.meta_description); setMessages((prev) => [...prev, { role: 'assistant', content: `✅ Meta description generated successfully`, type: 'success', }]); } else { throw new Error('No meta description returned from API'); } } catch (error) { console.error('Error generating meta description:', error); setMessages((prev) => [...prev, { role: 'system', content: `❌ Failed to generate meta description: ${error.message}`, type: 'error', }]); } finally { setIsGeneratingMeta(false); } }; const extractBlockPreview = (block) => { const direct = toTextValue( block.attributes?.content || block.attributes?.value || block.attributes?.caption || block.attributes?.title || '' ); if (direct) { return direct; } if (wp.blocks && typeof wp.blocks.getBlockContent === 'function') { const html = wp.blocks.getBlockContent(block); if (html) { const temp = document.createElement('div'); temp.innerHTML = html; return toTextValue(temp.textContent); } } return ''; }; const getBlockPreviewById = (clientId) => { if (!clientId) { return ''; } const allBlocks = select('core/block-editor').getBlocks(); const block = allBlocks.find((entry) => entry.clientId === clientId); if (!block) { return ''; } return extractBlockPreview(block); }; // Auto-scroll to bottom when new messages arrive React.useEffect(() => { if (messagesContainerRef.current) { const container = messagesContainerRef.current; container.scrollTop = container.scrollHeight; } }, [messages, isLoading]); React.useEffect(() => { loadSectionBlocks(); }, [postId]); React.useEffect(() => { if (!postId) { return; } 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 data = await response.json(); if (data && Array.isArray(data.messages) && data.messages.length > 0) { setMessages((prev) => (prev.length > 0 ? prev : data.messages)); } } catch (error) { // Ignore history load failures. } }; loadChatHistory(); }, [postId]); const resolveStreamTarget = (content) => { if (progressRegex.test(content)) { return 'timeline'; } if (content.length >= 6 || /[\s.!?]/.test(content)) { return 'assistant'; } return null; }; const normalizeMentionToken = (token) => { if (!token) { return ''; } return token .replace(/[\u2010-\u2015\u2212]/g, '-') .replace(/[.,;:!?)]*$/g, '') .toLowerCase(); }; const extractMentionsFromText = (text) => { const tokens = []; const mentionRegex = /@([^\s]+)/g; let match; while ((match = mentionRegex.exec(text))) { const normalized = normalizeMentionToken(match[1]); if (normalized) { tokens.push('@' + normalized); } } return tokens; }; const stripMentionsFromText = (text) => { if (!text) { return ''; } return text .replace(/@[\w-]+/g, '') .replace(/\s{2,}/g, ' ') .trim(); }; const parseInsertCommand = (text) => { const commands = [ { mode: 'add_below', regex: /^\s*(?:\/)?add below\b[:\-]?\s*/i }, { mode: 'add_above', regex: /^\s*(?:\/)?add above\b[:\-]?\s*/i }, { mode: 'append_code', regex: /^\s*(?:\/)?append code block\b[:\-]?\s*/i }, { mode: 'append_code', regex: /^\s*(?:\/)?append code\b[:\-]?\s*/i }, { mode: 'append_code', regex: /^\s*(?:\/)?add code block\b[:\-]?\s*/i }, ]; for (const command of commands) { if (command.regex.test(text)) { return { mode: command.mode, message: text.replace(command.regex, '').trim() }; } } return null; }; const getSlashOptions = (query) => { const options = [ { id: 'add-below', label: 'add below', sublabel: 'Insert a new paragraph below the target block', insertText: 'add below @' }, { id: 'add-above', label: 'add above', sublabel: 'Insert a new paragraph above the target block', insertText: 'add above @' }, { id: 'append-code-block', label: 'append code block', sublabel: 'Insert a code block below the target block', insertText: 'append code block @' }, { id: 'reformat', label: 'reformat', sublabel: 'Convert markdown-like text into blocks', insertText: 'reformat @' }, ]; if (!query) { return options; } const queryLower = query.toLowerCase(); return options.filter((option) => option.label.includes(queryLower)); }; const getBlockIndex = (clientId) => { const blockIndex = select('core/block-editor').getBlockIndex ? select('core/block-editor').getBlockIndex(clientId) : -1; if (blockIndex !== -1) { return blockIndex; } const allBlocks = select('core/block-editor').getBlocks(); return allBlocks.findIndex((block) => block.clientId === clientId); }; const resolveTargetBlockId = (mentionTokens) => { if (mentionTokens.length > 0) { const resolved = resolveBlockMentions(mentionTokens); if (resolved.length > 0) { return resolved[0]; } } const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); if (selectedBlockId) { return selectedBlockId; } const allBlocks = select('core/block-editor').getBlocks(); return allBlocks.length > 0 ? allBlocks[allBlocks.length - 1].clientId : null; }; const insertRefinementBlock = async (mode, message, mentionTokens, originalMessage) => { const initialTargetBlockId = resolveTargetBlockId(mentionTokens); const initialTargetBlock = initialTargetBlockId ? select('core/block-editor').getBlock(initialTargetBlockId) : null; const listParentId = initialTargetBlock?.name === 'core/list-item' ? getParentListId(initialTargetBlockId) : null; const targetBlockId = listParentId || initialTargetBlockId; if (!targetBlockId) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'No target block found. Select a block or mention one with @paragraph-1.' }]); setIsLoading(false); return; } const insertIndexBase = getBlockIndex(targetBlockId); const insertIndex = insertIndexBase === -1 ? undefined : insertIndexBase + (mode === 'add_above' ? 0 : 1); const { insertBlocks } = dispatch('core/block-editor'); const blockType = mode === 'append_code' ? 'core/code' : 'core/paragraph'; const newBlock = wp.blocks.createBlock( blockType, mode === 'append_code' ? { content: '', language: 'text' } : { content: '' } ); insertBlocks(newBlock, insertIndex); let refinementMessage = stripMentionsFromText(message); if (initialTargetBlock?.name === 'core/list-item') { const listItemText = extractBlockPreview(initialTargetBlock); if (listItemText) { refinementMessage = refinementMessage ? `${refinementMessage}\n\nAdd a short description for: "${listItemText}".` : `Add a short description for: "${listItemText}".`; } } const contextSnippets = getContextFromMentions(mentionTokens, initialTargetBlockId); if (!contextSnippets.length) { const headingContext = getHeadingContextForBlock(targetBlockId); if (headingContext) { contextSnippets.push(`Heading: ${headingContext}`); } getNearbyParagraphContext(targetBlockId, 2).forEach((snippet, index) => { contextSnippets.push(`Paragraph ${index + 1}: ${snippet}`); }); } if (contextSnippets.length) { refinementMessage = `${refinementMessage}\n\nContext snippets:\n${contextSnippets.map((snippet) => `- ${snippet}`).join('\n')}`; } const requestedBlockType = blockType; refinementMessage = `${refinementMessage}\n\nReturn only JSON: {"content":"...","blockType":"${requestedBlockType}"} with no extra text.`; if (mode === 'append_code') { refinementMessage += ' Put the code in "content" only, no backticks.'; } setInput(''); setMessages([...messages, { role: 'user', content: originalMessage }]); await handleChatRefinement( refinementMessage, [newBlock.clientId], { skipUserMessage: true, useDiffPlan: false } ); }; const streamGeneratePlan = async (request, options = {}) => { const { resume = false } = options; const normalizedRequest = { ...request, postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10) }; lastGenerationRequestRef.current = normalizedRequest; setIsLoading(true); // Capture snapshot before generation (only if not resuming) if (!resume) { pushUndoSnapshot('Article Generation'); } try { const response = await fetch(wpAgenticWriter.apiUrl + '/generate-plan', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ ...normalizedRequest, resume: resume }), }); if (!response.ok) { const error = await response.json(); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to generate article'), canRetry: true }]); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); const timeout = setTimeout(() => { if (isLoading) { console.error('Generation timeout - no response received'); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Request timeout. The AI is taking too long to respond. Please try again.', canRetry: true }]); setIsLoading(false); reader.cancel(); } }, 120000); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.type === 'plan') { setCost({ ...cost, session: cost.session + data.cost }); if (agentMode === 'planning' && data.plan) { updateOrCreatePlanMessage(data.plan); } } else if (data.type === 'title_update') { dispatch('core/editor').editPost({ title: data.title }); } else if (data.type === 'status') { if (data.status === 'complete') { continue; } setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon }; } return newMessages; }); } else if (data.type === 'conversational' || data.type === 'conversational_stream') { const cleanContent = (data.content || '') .replace(/~~~ARTICLE~+/g, '') .replace(/~~~ARTICLE~~~[\r\n]*/g, '') .trim(); if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === 'timeline') { updateOrCreateTimelineEntry(cleanContent); } else if (data.type === 'conversational') { setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); } else { setMessages(prev => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; } else { newMessages.push({ role: 'assistant', content: cleanContent }); } return newMessages; }); } } else if (data.type === 'block') { const { insertBlocks } = dispatch('core/block-editor'); let newBlock; if (data.block.blockName === 'core/paragraph') { const content = data.block.innerHTML?.match(/

(.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); } else if (data.block.blockName === 'core/heading') { const level = data.block.attrs?.level || 2; const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); } else if (data.block.blockName === 'core/list') { const listItems = (data.block.innerBlocks || []).map(item => { 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); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); } else if (data.block.blockName === 'core/image') { newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); } else if (data.block.blockName === 'core/code') { newBlock = wp.blocks.createBlock('core/code', data.block.attrs || {}); } if (newBlock) { insertBlocks(newBlock); } } else if (data.type === 'complete') { clearTimeout(timeout); setCost({ ...cost, session: cost.session + data.totalCost }); setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'complete', message: agentMode === 'planning' ? 'Outline ready.' : 'Article generated successfully!', completedAt: new Date() }; } return newMessages; }); } else if (data.type === 'error') { clearTimeout(timeout); setMessages(prev => [...prev, { role: 'system', type: 'error', content: data.message || 'An error occurred during article generation', canRetry: true }]); } } catch (parseError) { console.error('Failed to parse streaming data:', line, parseError); } } } } clearTimeout(timeout); } catch (error) { console.error('Article generation error:', error); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to generate article'), canRetry: true }]); } finally { setIsLoading(false); } }; const retryLastGeneration = () => { if (!lastGenerationRequestRef.current) { return; } setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'starting', message: 'Resuming generation...', timestamp: new Date() }]); streamGeneratePlan(lastGenerationRequestRef.current, { resume: true }); }; const retryLastExecute = () => { if (!lastExecuteRequestRef.current) { return; } executePlanFromCard({ retry: true }); }; const retryLastRefinement = () => { if (!lastRefineRequestRef.current) { return; } setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'starting', message: 'Retrying refinement...', timestamp: new Date() }]); handleChatRefinement( lastRefineRequestRef.current.message, lastRefineRequestRef.current.blocksOverride, lastRefineRequestRef.current.options ); }; const retryLastChat = async () => { if (!lastChatRequestRef.current) { return; } const userMessage = lastChatRequestRef.current.message; // Remove the last error message setMessages(prev => prev.filter(m => !(m.type === 'error' && m.retryType === 'chat'))); setIsLoading(true); try { const chatHistory = messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => ({ role: m.role, content: m.content })); const response = await fetch(wpAgenticWriter.apiUrl + '/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ messages: [...chatHistory, { role: 'user', content: userMessage }], postId: postId, type: 'chat', stream: true, postConfig: postConfig, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to chat'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let streamBuffer = ''; let fullContent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; streamBuffer += decoder.decode(value, { stream: true }); const lines = streamBuffer.split('\n'); streamBuffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) continue; try { const data = JSON.parse(line.slice(6)); if (data.type === 'conversational_stream' || data.type === 'conversational') { fullContent = data.content; setMessages(prev => { const lastMsg = prev[prev.length - 1]; if (lastMsg && lastMsg.role === 'assistant' && lastMsg.isStreaming) { return [...prev.slice(0, -1), { ...lastMsg, content: fullContent }]; } return [...prev, { role: 'assistant', content: fullContent, isStreaming: true }]; }); } else if (data.type === 'complete') { setMessages(prev => { const lastMsg = prev[prev.length - 1]; if (lastMsg && lastMsg.role === 'assistant') { return [...prev.slice(0, -1), { ...lastMsg, isStreaming: false }]; } return prev; }); } else if (data.type === 'error') { throw new Error(data.message || 'Chat error'); } } catch (e) { if (e.message !== 'Chat error') continue; throw e; } } } } catch (error) { const errorMsg = error.message || 'Failed to chat'; setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + errorMsg, canRetry: true, retryType: 'chat' }]); } finally { setIsLoading(false); } }; const createBlockFromPlan = (action) => { const blockType = action.blockType || 'core/paragraph'; const content = action.content || ''; if (blockType === 'core/image') { const match = content.match(/^!\[(.*?)\]\(([^)\s]+)(?:\s+"[^"]*")?\)\s*$/); const alt = match ? match[1] : ''; const url = match ? match[2] : ''; return wp.blocks.createBlock('core/image', { id: 0, url: url, alt: alt, caption: '', sizeSlug: 'large', linkDestination: 'none' }); } if (blockType === 'core/heading') { return wp.blocks.createBlock('core/heading', { level: action.level || 2, content: content }); } 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); } if (blockType === 'core/code') { return wp.blocks.createBlock('core/code', { content: content, language: action.language || 'text' }); } return wp.blocks.createBlock(blockType, { content: content }); }; const normalizePlanActions = (plan) => { if (!plan || !plan.actions) { return []; } if (Array.isArray(plan.actions)) { return plan.actions; } return Object.values(plan.actions); }; const buildPlanPreviewItem = (action, index) => { if (!action || !action.action) { return { title: 'Unknown action' }; } const type = action.blockType ? ` (${action.blockType.replace('core/', '')})` : ''; const content = (action.content || '').replace(/\s+/g, ' ').trim(); const contentPreview = content ? `"${content.substring(0, 80)}${content.length > 80 ? '...' : ''}"` : ''; const before = getBlockPreviewById(action.blockId); const beforePreview = before ? `"${before.substring(0, 80)}${before.length > 80 ? '...' : ''}"` : ''; const targetLabel = before ? ` "${before.substring(0, 40)}${before.length > 40 ? '...' : ''}"` : ''; const targetPreview = beforePreview || '"Target block not found"'; const blockId = action.blockId || null; switch (action.action) { case 'keep': return { title: 'Keep' }; case 'delete': return { title: `Delete${targetLabel}`, target: targetPreview, targetLabel: 'Target', blockId, }; case 'replace': return { title: `Replace${targetLabel}${type}`, before: beforePreview, after: contentPreview, blockId, }; case 'change_type': return { title: `Change type${targetLabel}${type}`, before: beforePreview, after: contentPreview, blockId, }; case 'insert_before': return { title: `Insert before${targetLabel}${type}`, target: targetPreview, targetLabel: 'Target', after: contentPreview, blockId, }; case 'insert_after': return { title: `Insert after${targetLabel}${type}`, target: targetPreview, targetLabel: 'Target', after: contentPreview, blockId, }; default: return { title: `${action.action}${targetLabel}${type}`, after: contentPreview, blockId, }; } }; const normalizePlanSectionTitle = (section) => { const heading = (section?.heading || section?.title || '').toString(); return heading.replace(/<[^>]+>/g, '').trim().toLowerCase(); }; const upsertSectionBlock = (sectionId, blockId) => { if (!sectionId || !blockId) { return; } const sectionMap = sectionBlocksRef.current[sectionId] || []; if (!sectionMap.includes(blockId)) { sectionBlocksRef.current[sectionId] = [...sectionMap, blockId]; } blockSectionRef.current[blockId] = sectionId; }; const removeSectionBlock = (sectionId, blockId) => { if (!sectionId || !blockId) { return; } const sectionMap = sectionBlocksRef.current[sectionId] || []; sectionBlocksRef.current[sectionId] = sectionMap.filter((id) => id !== blockId); delete blockSectionRef.current[blockId]; }; const loadSectionBlocks = async () => { if (!postId) { return; } try { const response = await fetch(`${wpAgenticWriter.apiUrl}/section-blocks/${postId}`, { method: 'GET', headers: { 'X-WP-Nonce': wpAgenticWriter.nonce, }, }); if (!response.ok) { return; } const data = await response.json(); if (data && data.sectionBlocks && typeof data.sectionBlocks === 'object') { sectionBlocksRef.current = data.sectionBlocks; blockSectionRef.current = {}; Object.entries(data.sectionBlocks).forEach(([sectionId, blockIds]) => { if (Array.isArray(blockIds)) { blockIds.forEach((blockId) => { blockSectionRef.current[blockId] = sectionId; }); } }); } } catch (error) { // Ignore load failures for section mapping. } }; const saveSectionBlocks = async (sectionId) => { if (!sectionId || !postId) { return; } const blockIds = sectionBlocksRef.current[sectionId] || []; try { await fetch(`${wpAgenticWriter.apiUrl}/section-blocks`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ postId: postId, sectionId: sectionId, blockIds: blockIds, }), }); } catch (error) { // Ignore save failures for section mapping. } }; const ensurePlanTasks = (plan) => { if (!plan || !Array.isArray(plan.sections)) { return plan; } const nextSections = plan.sections.map((section, index) => { const id = section?.id || `section-${index + 1}`; const status = section?.status || 'pending'; return { ...section, id, status }; }); return { ...plan, sections: nextSections }; }; const getTargetedRefinementBlocks = (message) => { if (!message) { return null; } const codeKeywords = /(kode|coding|code|script|snippet|skrip)/i; if (!codeKeywords.test(message)) { return null; } const allBlocks = select('core/block-editor').getBlocks(); const codeBlocks = allBlocks.filter((block) => block.name === 'core/code'); if (codeBlocks.length === 0) { return null; } const affectedSections = new Set(); codeBlocks.forEach((block) => { const sectionId = blockSectionRef.current[block.clientId]; if (sectionId) { affectedSections.add(sectionId); } }); if (affectedSections.size === 0) { return null; } const targetIds = []; affectedSections.forEach((sectionId) => { const blockIds = sectionBlocksRef.current[sectionId] || []; blockIds.forEach((blockId) => { targetIds.push(blockId); }); }); return [...new Set(targetIds)]; }; const findBestPlanSectionMatch = (message) => { const plan = currentPlanRef.current; if (!plan || !Array.isArray(plan.sections) || !message) { return null; } const stopwords = new Set([ 'dalam', 'poin', 'bagian', 'yang', 'dan', 'atau', 'untuk', 'dengan', 'ada', 'tidak', 'lebih', 'ini', 'itu', 'seperti', 'agar', 'akan', 'jadi', 'fokus', 'tulis', 'ulang', 'hapus', 'tambahkan', 'pembahasan', 'pada', 'berikan', 'gunakan', 'jelaskan', 'buat', ]); const tokens = message .toLowerCase() .replace(/[^a-z0-9\s]/g, ' ') .split(/\s+/) .filter((token) => token.length > 3 && !stopwords.has(token)); if (tokens.length === 0) { return null; } let best = null; let bestScore = 0; plan.sections.forEach((section) => { const sectionText = [ section?.heading, section?.title, section?.description, Array.isArray(section?.content) && section.content.length > 0 ? section.content[0]?.content : '', ].filter(Boolean).join(' ').toLowerCase(); if (!sectionText) { return; } let score = 0; tokens.forEach((token) => { if (sectionText.includes(token)) { score += 1; } }); if (score > bestScore) { bestScore = score; best = section; } }); if (!best || bestScore < 2) { return null; } return best; }; const updatePlanSectionStatus = (sectionId, status) => { if (!sectionId) { return; } setMessages(prev => { const newMessages = [...prev]; for (let i = newMessages.length - 1; i >= 0; i--) { if (newMessages[i].type === 'plan' && newMessages[i].plan?.sections) { const sections = newMessages[i].plan.sections.map((section) => { if (section.id === sectionId) { return { ...section, status: status }; } return section; }); const plan = { ...newMessages[i].plan, sections }; newMessages[i] = { ...newMessages[i], plan }; currentPlanRef.current = plan; break; } } return newMessages; }); }; const findSectionInsertIndex = (plan, sectionId) => { const allBlocks = select('core/block-editor').getBlocks(); if (!plan || !Array.isArray(plan.sections) || !sectionId) { return allBlocks.length; } const sections = plan.sections; const sectionIndex = sections.findIndex((section) => section.id === sectionId); if (sectionIndex === -1) { return allBlocks.length; } for (let i = sectionIndex + 1; i < sections.length; i++) { const nextSection = sections[i]; const nextStatus = nextSection?.status || 'pending'; if (nextStatus !== 'done') { continue; } const nextHeading = normalizePlanSectionTitle(nextSection); if (!nextHeading) { continue; } const anchorIndex = allBlocks.findIndex((block) => { if (block.name !== 'core/heading') { return false; } const content = normalizePlanSectionTitle({ heading: block.attributes?.content }); return content === nextHeading; }); if (anchorIndex !== -1) { return anchorIndex; } } return allBlocks.length; }; const updateOrCreatePlanMessage = (plan, options = {}) => { const { append = false } = options; const normalizedPlan = ensurePlanTasks(plan); currentPlanRef.current = normalizedPlan; setMessages((prev) => { const newMessages = [...prev]; if (!append) { for (let i = newMessages.length - 1; i >= 0; i--) { if (newMessages[i].type === 'plan') { newMessages[i] = { ...newMessages[i], plan: normalizedPlan }; return newMessages; } } } newMessages.push({ role: 'assistant', type: 'plan', plan: normalizedPlan }); return newMessages; }); }; const shouldSkipPlanningCompletion = (content) => { if (agentMode !== 'planning') { return false; } const text = String(content || '').toLowerCase(); return text.includes('article generation complete') || text.includes('content has been added to your editor') || text.includes('article generated successfully'); }; const executePlanFromCard = async (options = {}) => { if (isLoading) { return; } const plan = currentPlanRef.current; const pendingCount = Array.isArray(plan?.sections) ? plan.sections.filter((section) => section.status !== 'done').length : null; if (pendingCount === 0) { setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'complete', message: 'All outline items are already written.', timestamp: new Date() }]); return; } const { retry = false } = options; lastExecuteRequestRef.current = { postId: postId, stream: true, postConfig: postConfig, detectedLanguage: detectedLanguage, }; setIsLoading(true); setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'writing', message: retry ? 'Retrying outline...' : 'Writing from outline...', timestamp: new Date() }]); sectionInsertIndexRef.current = {}; activeSectionIdRef.current = null; try { const response = await fetch(wpAgenticWriter.apiUrl + '/execute-article', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify(lastExecuteRequestRef.current), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to execute outline'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let streamBuffer = ''; const timeout = setTimeout(() => { if (isLoading) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Request timeout. The AI is taking too long to respond. Please try again.' }]); setIsLoading(false); reader.cancel(); } }, 120000); while (true) { const { done, value } = await reader.read(); if (done) break; streamBuffer += decoder.decode(value, { stream: true }); const lines = streamBuffer.split('\n'); streamBuffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) { continue; } try { const data = JSON.parse(line.slice(6)); if (data.type === 'title_update') { dispatch('core/editor').editPost({ title: data.title }); } else if (data.type === 'section_start') { activeSectionIdRef.current = data.sectionId || null; const insertIndex = findSectionInsertIndex(currentPlanRef.current, data.sectionId); if (data.sectionId) { sectionInsertIndexRef.current[data.sectionId] = insertIndex; sectionBlocksRef.current[data.sectionId] = sectionBlocksRef.current[data.sectionId] || []; } updatePlanSectionStatus(data.sectionId, 'in_progress'); } else if (data.type === 'status') { if (data.status === 'complete') { continue; } setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon }; } return newMessages; }); } else if (data.type === 'block') { const { insertBlocks } = dispatch('core/block-editor'); const newBlock = createBlocksFromSerialized(data.block); if (newBlock) { const sectionId = data.sectionId || activeSectionIdRef.current; const insertIndex = sectionId ? sectionInsertIndexRef.current[sectionId] : undefined; if (typeof insertIndex === 'number') { insertBlocks(newBlock, insertIndex); sectionInsertIndexRef.current[sectionId] = insertIndex + 1; } else { insertBlocks(newBlock); } if (sectionId) { upsertSectionBlock(sectionId, newBlock.clientId); } } } else if (data.type === 'section_complete') { updatePlanSectionStatus(data.sectionId, 'done'); saveSectionBlocks(data.sectionId); } else if (data.type === 'complete') { clearTimeout(timeout); if (data.totalCost) { setCost({ ...cost, session: cost.session + data.totalCost }); } setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'complete', message: 'Article generated successfully!', completedAt: new Date() }; } return newMessages; }); setAgentMode('writing'); setIsLoading(false); } else if (data.type === 'error') { clearTimeout(timeout); throw new Error(data.message || 'Failed to execute outline'); } } catch (parseError) { console.error('Failed to parse streaming data:', line, parseError); } } } clearTimeout(timeout); } catch (error) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to execute outline'), canRetry: true, retryType: 'execute', }]); } finally { setIsLoading(false); } }; const clearChatContext = async () => { if (isLoading) { 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.'; if (!window.confirm(confirmMessage)) { return; } try { await fetch(wpAgenticWriter.apiUrl + '/clear-context', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ postId }), }); setMessages([]); setInClarification(false); setQuestions([]); setCurrentQuestionIndex(0); setAnswers([]); setPendingRefinement(null); setPendingEditPlan(null); streamTargetRef.current = null; } catch (error) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: Failed to clear chat context.', }]); } }; const createBlocksFromSerialized = (block) => { if (!block || !block.blockName) { return null; } const attrs = { ...(block.attrs || {}) }; // Handle code blocks if (block.blockName === 'core/code' && !attrs.content && block.innerHTML) { const match = block.innerHTML.match(/([\s\S]*?)<\/code>/i); if (match && match[1]) { attrs.content = match[1] .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&') .replace(/"/g, '"'); } } // Handle table blocks - extract head and body from innerHTML if (block.blockName === 'core/table' && block.innerHTML) { const headMatch = block.innerHTML.match(/([\s\S]*?)<\/thead>/i); const bodyMatch = block.innerHTML.match(/([\s\S]*?)<\/tbody>/i); if (headMatch || bodyMatch) { attrs.head = []; attrs.body = []; // Parse thead rows if (headMatch) { const headRows = headMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; headRows.forEach(row => { const cells = []; const cellMatches = row.match(/([\s\S]*?)<\/t[hd]>/gi) || []; cellMatches.forEach(cell => { const content = cell.replace(/<\/?t[hd]>/gi, ''); cells.push({ content, tag: 'th' }); }); if (cells.length > 0) attrs.head.push({ cells }); }); } // Parse tbody rows if (bodyMatch) { const bodyRows = bodyMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; bodyRows.forEach(row => { const cells = []; const cellMatches = row.match(/([\s\S]*?)<\/td>/gi) || []; cellMatches.forEach(cell => { const content = cell.replace(/<\/?td>/gi, ''); cells.push({ content, tag: 'td' }); }); if (cells.length > 0) attrs.body.push({ cells }); }); } } } // Handle button blocks from [CTA:...] syntax if (block.blockName === 'core/buttons' || block.blockName === 'core/button') { if (block.blockName === 'core/button') { return wp.blocks.createBlock('core/buttons', {}, [ wp.blocks.createBlock('core/button', attrs) ]); } } if (block.innerBlocks && block.innerBlocks.length > 0) { const innerBlocks = block.innerBlocks.map((innerBlock) => ( createBlocksFromSerialized(innerBlock) )).filter(Boolean); return wp.blocks.createBlock(block.blockName, attrs, innerBlocks); } return wp.blocks.createBlock(block.blockName, attrs); }; const reformatBlocks = async (blocksToReformat, originalMessage) => { if (isLoading) { return; } if (!blocksToReformat || blocksToReformat.length === 0) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'No blocks found to reformat.' }]); return; } setIsLoading(true); setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'refining', message: `Reformatting ${blocksToReformat.length} block(s)...`, timestamp: new Date() }]); try { const response = await fetch(wpAgenticWriter.apiUrl + '/reformat-blocks', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ blocks: blocksToReformat, postId: postId, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to reformat blocks'); } const data = await response.json(); const results = data.results || []; const { replaceBlocks } = dispatch('core/block-editor'); const currentTitle = select('core/editor').getEditedPostAttribute('title') || ''; results.forEach((result) => { const newBlocks = (result.blocks || []).map(createBlocksFromSerialized).filter(Boolean); if (newBlocks.length > 0) { replaceBlocks(result.clientId, newBlocks); } }); setMessages(prev => [...prev, { role: 'system', type: 'timeline', status: 'complete', message: `Reformatted ${results.length} block(s).`, timestamp: new Date(), completedAt: new Date() }]); if (data.recommended_title) { setMessages(prev => [...prev, { role: 'assistant', content: `Suggested title: ${data.recommended_title}` }]); if (data.title_updated || !currentTitle) { dispatch('core/editor').editPost({ title: data.recommended_title }); } } } catch (error) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to reformat blocks') }]); } finally { setIsLoading(false); } }; const revisePlanFromPrompt = async (instruction) => { if (isLoading) { return; } const existingPlan = currentPlanRef.current; if (!existingPlan) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'No outline found to revise. Generate an outline first.' }]); return; } setIsLoading(true); setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'planning', message: 'Updating outline...', timestamp: new Date() }]); try { const response = await fetch(wpAgenticWriter.apiUrl + '/revise-plan', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ instruction: instruction, plan: existingPlan, postId: postId, postConfig: postConfig, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to revise outline'); } const data = await response.json(); if (data.plan) { updateOrCreatePlanMessage(data.plan, { append: true }); } if (data.cost) { setCost({ ...cost, session: cost.session + data.cost }); } setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'complete', message: 'Outline updated.', completedAt: new Date() }; } return newMessages; }); } catch (error) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to revise outline') }]); } finally { setIsLoading(false); } }; const applyEditPlan = (plan) => { const actions = normalizePlanActions(plan); if (actions.length === 0) { setPendingEditPlan(null); return; } // Capture snapshot before applying changes pushUndoSnapshot('Apply Edit Plan'); const { replaceBlocks, insertBlocks, removeBlocks } = dispatch('core/block-editor'); const allBlocks = select('core/block-editor').getBlocks(); const baseIndexById = new Map(allBlocks.map((block, index) => [block.clientId, index])); const insertOffsets = {}; const existingIds = new Set(allBlocks.map((block) => block.clientId)); actions.forEach((action) => { if (action.action === 'keep') { return; } if (action.blockId && !existingIds.has(action.blockId)) { return; } if (action.action === 'delete' && action.blockId) { removeBlocks(action.blockId); return; } if (action.action === 'change_type' && action.blockId) { const newBlock = createBlockFromPlan(action); replaceBlocks(action.blockId, newBlock); return; } if (action.action === 'replace' && action.blockId) { const newBlock = createBlockFromPlan(action); replaceBlocks(action.blockId, newBlock); return; } if ((action.action === 'insert_after' || action.action === 'insert_before') && action.blockId) { const baseIndex = baseIndexById.get(action.blockId); const offsets = insertOffsets[action.blockId] || { before: 0, after: 0 }; let insertIndex; if (typeof baseIndex === 'number') { if (action.action === 'insert_before') { insertIndex = baseIndex + offsets.before; offsets.before += 1; } else { insertIndex = baseIndex + offsets.before + 1 + offsets.after; offsets.after += 1; } } insertOffsets[action.blockId] = offsets; const newBlock = createBlockFromPlan(action); insertBlocks(newBlock, insertIndex); } }); setPendingEditPlan(null); setMessages(prev => [...prev, { role: 'system', type: 'timeline', status: 'complete', message: 'Changes applied.', }]); }; const cancelEditPlan = () => { setPendingEditPlan(null); setMessages(prev => [...prev, { role: 'system', type: 'timeline', status: 'inactive', message: 'Changes cancelled.', }]); }; const formatClarificationContext = (questionsList, answersMap) => { if (!questionsList || questionsList.length === 0) { return ''; } const lines = []; questionsList.forEach((question) => { const answer = answersMap[question.id]; if (!answer) { return; } lines.push(`- ${question.question || question.prompt || 'Question'}: ${answer}`); }); if (lines.length === 0) { return ''; } return `\n\nClarification Answers:\n${lines.join('\n')}`; }; // Auto-select first option when question changes React.useEffect(() => { if (inClarification && questions.length > 0 && questions[currentQuestionIndex]) { const currentQuestion = questions[currentQuestionIndex]; if (currentQuestion.type === 'single_choice' && currentQuestion.options && currentQuestion.options.length > 0 && !answers[currentQuestion.id]) { const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = currentQuestion.options[0].value; setAnswers(newAnswers); } } }, [currentQuestionIndex, questions, inClarification]); /** * Remove duplicate adjacent heading blocks */ const removeDuplicateHeadings = (blocks) => { if (!blocks || blocks.length === 0) { return blocks; } const cleanedBlocks = []; let lastHeadingContent = null; for (const block of blocks) { if (block.name === 'core/heading') { const currentHeading = (block.attributes?.content || '').trim().toLowerCase(); if (currentHeading === lastHeadingContent) { console.log('WP Agentic Writer: Removed duplicate heading:', block.attributes.content); continue; } lastHeadingContent = currentHeading; } else { lastHeadingContent = null; } cleanedBlocks.push(block); } return cleanedBlocks; }; // Send message and generate article. // Resolve block mentions to client IDs const getRefineableBlocks = () => { const allBlocks = select('core/block-editor').getBlocks(); return allBlocks.filter((block) => { if (!block.name || !block.name.startsWith('core/')) { return false; } // Filter out empty blocks (e.g., default empty paragraph on new posts) const content = block.attributes?.content || ''; const hasInnerBlocks = block.innerBlocks && block.innerBlocks.length > 0; // Consider block as refineable only if it has content or inner blocks return content.trim().length > 0 || hasInnerBlocks; }); }; const getListItemBlocks = () => { const allBlocks = select('core/block-editor').getBlocks(); const listItems = []; let listBlockIndex = 0; allBlocks.forEach((block) => { if (block.name !== 'core/list') { return; } listBlockIndex += 1; const innerItems = Array.isArray(block.innerBlocks) ? block.innerBlocks : []; innerItems.forEach((itemBlock, itemIndex) => { if (itemBlock.name !== 'core/list-item') { return; } listItems.push({ block: itemBlock, parentId: block.clientId, listIndex: listBlockIndex, itemIndex: itemIndex }); }); }); return listItems; }; const resolveExplicitListItem = (listIndex, itemIndex) => { const listItems = getListItemBlocks(); return listItems.find( (item) => item.listIndex === listIndex && item.itemIndex === itemIndex ); }; const getParentListId = (blockId) => { const getParents = select('core/block-editor').getBlockParents; if (!getParents) { return null; } const parentIds = getParents(blockId); for (const parentId of parentIds) { const parentBlock = select('core/block-editor').getBlock(parentId); if (parentBlock?.name === 'core/list') { return parentId; } } return null; }; const getBlockContentForContext = (blockId) => { const block = blockId ? select('core/block-editor').getBlock(blockId) : null; if (!block) { return ''; } const content = extractBlockPreview(block); return content ? content.trim() : ''; }; const getHeadingContextForBlock = (blockId) => { const allBlocks = select('core/block-editor').getBlocks(); const startIndex = allBlocks.findIndex((block) => block.clientId === blockId); if (startIndex === -1) { return ''; } for (let i = startIndex - 1; i >= 0; i -= 1) { if (allBlocks[i].name === 'core/heading') { return extractBlockPreview(allBlocks[i]) || ''; } } return ''; }; const getNearbyParagraphContext = (blockId, limit = 2) => { const allBlocks = select('core/block-editor').getBlocks(); const startIndex = allBlocks.findIndex((block) => block.clientId === blockId); if (startIndex === -1) { return []; } const snippets = []; for (let i = startIndex - 1; i >= 0 && snippets.length < limit; i -= 1) { if (allBlocks[i].name === 'core/paragraph') { const preview = extractBlockPreview(allBlocks[i]); if (preview) { snippets.push(preview.trim()); } } if (allBlocks[i].name === 'core/heading') { break; } } return snippets.reverse(); }; const getContextFromMentions = (mentionTokens, excludeId) => { const mentionIds = resolveBlockMentions(mentionTokens).filter((id) => id && id !== excludeId); const uniqueIds = [...new Set(mentionIds)]; return uniqueIds .map((id) => getBlockContentForContext(id)) .filter((content) => content); }; const resolveBlockMentions = (mentions) => { const allBlocks = select('core/block-editor').getBlocks(); const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); const resolved = []; const listItems = getListItemBlocks(); mentions.forEach(mention => { const type = normalizeMentionToken(mention.replace('@', '')); const match = type.match(/^([a-z0-9-]+)-(\d+)$/i); const listItemMatch = type.match(/^(?:listitem|list-item|li)-(\d+)$/i); const explicitListItemMatch = type.match(/^list-(\d+)\.list-item-(\d+)$/i); switch (type) { case 'this': if (selectedBlockId) { resolved.push(selectedBlockId); } break; case 'previous': if (selectedBlockId) { const selectedIndex = allBlocks.findIndex(b => b.clientId === selectedBlockId); if (selectedIndex > 0) { resolved.push(allBlocks[selectedIndex - 1].clientId); } } break; case 'next': if (selectedBlockId) { const selectedIndex = allBlocks.findIndex(b => b.clientId === selectedBlockId); if (selectedIndex < allBlocks.length - 1) { resolved.push(allBlocks[selectedIndex + 1].clientId); } } break; case 'all': getRefineableBlocks().forEach((block) => { resolved.push(block.clientId); }); break; default: if (explicitListItemMatch) { const listIndex = parseInt(explicitListItemMatch[1], 10); const itemIndex = parseInt(explicitListItemMatch[2], 10); const item = resolveExplicitListItem(listIndex, itemIndex); if (item) { resolved.push(item.block.clientId); } break; } if (listItemMatch) { const rawIndex = parseInt(listItemMatch[1], 10); const targetIndex = rawIndex <= 0 ? 1 : rawIndex; const listItem = listItems[targetIndex - 1]; if (listItem) { resolved.push(listItem.block.clientId); } break; } // Handle "paragraph-1", "heading-2", "list-1" format if (match) { const blockType = 'core/' + match[1]; const blockIndex = parseInt(match[2]) - 1; // 1-based to 0-based let currentIndex = 0; allBlocks.forEach((block) => { if (block.name === blockType) { if (currentIndex === blockIndex) { resolved.push(block.clientId); } currentIndex++; } }); } break; } }); return [...new Set(resolved)]; // Remove duplicates }; // Handle chat-based refinement const handleChatRefinement = async (message, blocksOverride = null, options = {}) => { const { skipUserMessage = false, useDiffPlan = true } = options; lastRefineRequestRef.current = { message, blocksOverride, options }; // Capture snapshot before refinement pushUndoSnapshot('Block Refinement'); // Parse mentions from message const mentionRegex = /@([a-z0-9-]+(?:-\d+)?|this|previous|next|all)/gi; const mentionMatches = [...message.matchAll(mentionRegex)]; const mentions = mentionMatches.map(m => '@' + m[1]); // Resolve to block client IDs const blocksToRefine = blocksOverride || resolveBlockMentions(mentions); if (blocksToRefine.length === 0) { // No valid mentions found - alert user setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'No valid blocks found to refine. Try @this, @previous, @next, @all, @paragraph-1, @listitem-3, or @list-3.list-item-0.' }]); setIsLoading(false); return; } const serializeBlockForApi = (block) => { if (!block) { return null; } return { clientId: block.clientId, name: block.name, attributes: block.attributes || {}, innerBlocks: Array.isArray(block.innerBlocks) ? block.innerBlocks.map(serializeBlockForApi).filter(Boolean) : [], }; }; // Get actual block data snapshot from editor const allBlocksSnapshot = select('core/block-editor').getBlocks(); const normalizedAllBlocks = allBlocksSnapshot .map(serializeBlockForApi) .filter(Boolean); const blocksToRefineData = blocksToRefine .map((clientId) => normalizedAllBlocks.find((block) => block.clientId === clientId)) .filter(Boolean); // Add user message to chat if (!skipUserMessage) { setMessages([...messages, { role: 'user', content: message }]); } // Add timeline entry setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'refining', message: `Refining ${blocksToRefine.length} block(s)...`, timestamp: new Date() }]); setIsLoading(true); try { // Get selected block const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); // Call refinement endpoint with actual block data const response = await fetch(wpAgenticWriter.apiUrl + '/refine-from-chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ topic: message, context: message, selectedBlockClientId: selectedBlockId, blocksToRefine: blocksToRefineData, // Send actual block objects allBlocks: normalizedAllBlocks, postId: postId, stream: true, diffPlan: useDiffPlan, postConfig: postConfig, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Refinement failed'); } // Handle streaming response streamTargetRef.current = null; const reader = response.body.getReader(); const decoder = new TextDecoder(); let streamBuffer = ''; let refinedCount = 0; const updatedSectionIds = new Set(); const { replaceBlocks } = dispatch('core/block-editor'); let refinementFailed = false; let refinementErrorMessage = ''; while (true) { const { done, value } = await reader.read(); if (done) break; streamBuffer += decoder.decode(value, { stream: true }); const lines = streamBuffer.split('\n'); streamBuffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.type === 'error') { refinementFailed = true; refinementErrorMessage = data.message || 'Refinement failed.'; break; } else if (data.type === 'edit_plan') { setPendingEditPlan(data.plan); setMessages(prev => [...prev, { role: 'system', type: 'edit_plan', plan: data.plan, }]); } else if (data.type === 'block') { // Replace block in editor const blockData = data.block; if (blockData.blockName && blockData.attrs) { let newBlock; // Create block using WordPress createBlock API if (blockData.innerBlocks && blockData.innerBlocks.length > 0) { // For lists with inner blocks const innerBlocks = blockData.innerBlocks.map(innerB => { return wp.blocks.createBlock( innerB.blockName, innerB.attrs ); }); newBlock = wp.blocks.createBlock( blockData.blockName, blockData.attrs, innerBlocks ); } else { // For simple blocks (paragraph, heading) newBlock = wp.blocks.createBlock( blockData.blockName, blockData.attrs ); } // Replace the target block if (newBlock && newBlock.name) { const sectionId = blockSectionRef.current[blockData.clientId]; replaceBlocks(blockData.clientId, newBlock); if (sectionId) { removeSectionBlock(sectionId, blockData.clientId); upsertSectionBlock(sectionId, newBlock.clientId); updatedSectionIds.add(sectionId); } } } refinedCount++; } else if (data.type === 'complete') { // Update timeline setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'complete', message: `Refined ${refinedCount} block(s) successfully`, timestamp: new Date() }; } return newMessages; }); // Show completion message setMessages(prev => [...prev, { role: 'assistant', content: `✅ Done! I've refined ${refinedCount} block(s) as requested.` }]); // Update cost if (data.totalCost) { setCost({ ...cost, session: cost.session + data.totalCost }); } updatedSectionIds.forEach((sectionId) => { saveSectionBlocks(sectionId); }); } } catch (e) { console.error('Failed to parse streaming data:', line, e); } } if (refinementFailed) { break; } } if (refinementFailed) { break; } } if (refinementFailed) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: `Refinement stopped: ${refinementErrorMessage}`, canRetry: true, retryType: 'refine' }]); setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'error', message: 'Refinement stopped (edit plan failed)', }; } return newMessages; }); } } catch (error) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + error.message, canRetry: true, retryType: 'refine' }]); // Update timeline to show error setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'error', message: 'Refinement failed', }; } return newMessages; }); } finally { setIsLoading(false); } }; // Get mention options for autocomplete const getMentionOptions = (query) => { const allBlocks = select('core/block-editor').getBlocks(); const selectedBlockId = select('core/block-editor').getSelectedBlockClientId(); const options = []; // Add special mentions if (!query || 'this'.includes(query.toLowerCase())) { options.push({ id: 'this', label: '@this', sublabel: 'Currently selected block', type: 'special' }); } if (!query || 'previous'.includes(query.toLowerCase())) { options.push({ id: 'previous', label: '@previous', sublabel: 'Block before current selection', type: 'special' }); } if (!query || 'next'.includes(query.toLowerCase())) { options.push({ id: 'next', label: '@next', sublabel: 'Block after current selection', type: 'special' }); } if (!query || 'all'.includes(query.toLowerCase())) { options.push({ id: 'all', label: '@all', sublabel: 'All content blocks', type: 'special' }); } // Add numbered blocks for core blocks const blockCounters = {}; const queryLower = query.toLowerCase(); let listItemIndex = 0; let listBlockIndex = 0; allBlocks.forEach((block) => { if (!block.name || !block.name.startsWith('core/')) { return; } const typeName = block.name.replace('core/', ''); blockCounters[typeName] = (blockCounters[typeName] || 0) + 1; const blockLabel = `@${typeName}-${blockCounters[typeName]}`; const content = extractBlockPreview(block); const contentLower = content.toLowerCase(); if (!query || blockLabel.includes(queryLower) || contentLower.startsWith(queryLower)) { const truncatedContent = content.length > 40 ? content.substring(0, 40) + '...' : content; options.push({ id: blockLabel, label: String(blockLabel), sublabel: truncatedContent || String(typeName), type: 'block', clientId: block.clientId }); } if (block.name === 'core/list') { listBlockIndex += 1; const innerItems = Array.isArray(block.innerBlocks) ? block.innerBlocks : []; innerItems.forEach((itemBlock, itemIndex) => { if (itemBlock.name !== 'core/list-item') { return; } listItemIndex += 1; const itemLabel = `@listitem-${listItemIndex}`; const explicitLabel = `@list-${listBlockIndex}.list-item-${itemIndex}`; const itemContent = extractBlockPreview(itemBlock); const itemLower = itemContent.toLowerCase(); if (!query || itemLabel.includes(queryLower) || explicitLabel.includes(queryLower) || itemLower.startsWith(queryLower)) { const truncatedItem = itemContent.length > 40 ? itemContent.substring(0, 40) + '...' : itemContent; options.push({ id: itemLabel, label: String(explicitLabel), sublabel: truncatedItem ? `List ${listBlockIndex}: ${truncatedItem}` : `List ${listBlockIndex} item`, type: 'list-item', clientId: itemBlock.clientId, parentClientId: block.clientId }); } }); } }); return options; }; React.useEffect(() => { const handleInsertMention = (event) => { const token = event?.detail?.token; if (!token) { return; } setActiveTab('chat'); setInput((prev) => { const prefix = prev && !/\s$/.test(prev) ? prev + ' ' : prev; return `${prefix}${token}`; }); setTimeout(() => { const inputNode = inputRef.current?.textarea || inputRef.current; if (inputNode) { inputNode.focus(); inputNode.selectionStart = inputNode.selectionEnd = inputNode.value.length; } const mentionOptionsList = getMentionOptions(''); setMentionOptions(mentionOptionsList); setShowMentionAutocomplete(mentionOptionsList.length > 0); }, 0); }; window.addEventListener('wpaw:insert-mention', handleInsertMention); return () => window.removeEventListener('wpaw:insert-mention', handleInsertMention); }, [getMentionOptions]); // Handle input change for mention detection const handleInputChange = (value) => { setInput(value); // Check if user is typing a mention const inputNode = inputRef.current?.textarea || inputRef.current; const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length; const textBeforeCursor = value.substring(0, cursorPosition); const mentionMatch = textBeforeCursor.match(/@(\w*)$/); const slashMatch = textBeforeCursor.match(/\/([\w\s]*)$/); if (mentionMatch) { const query = mentionMatch[1]; setMentionQuery(query); const options = getMentionOptions(query); setMentionOptions(options); setShowMentionAutocomplete(options.length > 0); setMentionCursorIndex(0); setShowSlashAutocomplete(false); setSlashOptions([]); } else if (slashMatch) { const query = slashMatch[1]; setSlashQuery(query); const options = getSlashOptions(query); setSlashOptions(options); setShowSlashAutocomplete(options.length > 0); setSlashCursorIndex(0); setShowMentionAutocomplete(false); setMentionOptions([]); } else { setShowMentionAutocomplete(false); setMentionOptions([]); setShowSlashAutocomplete(false); setSlashOptions([]); } }; // Handle keyboard navigation in autocomplete const handleKeyDown = (e) => { if (!showMentionAutocomplete && !showSlashAutocomplete) { if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) { sendMessage(); } return; } if (showMentionAutocomplete && e.keyCode === 40) { // Down arrow e.preventDefault(); setMentionCursorIndex(prev => (prev + 1) % mentionOptions.length); } else if (showMentionAutocomplete && e.keyCode === 38) { // Up arrow e.preventDefault(); setMentionCursorIndex(prev => (prev - 1 + mentionOptions.length) % mentionOptions.length); } else if (showMentionAutocomplete && e.keyCode === 13) { // Enter e.preventDefault(); if (mentionOptions[mentionCursorIndex]) { insertMention(mentionOptions[mentionCursorIndex]); } } else if (showSlashAutocomplete && e.keyCode === 40) { // Down arrow e.preventDefault(); setSlashCursorIndex(prev => (prev + 1) % slashOptions.length); } else if (showSlashAutocomplete && e.keyCode === 38) { // Up arrow e.preventDefault(); setSlashCursorIndex(prev => (prev - 1 + slashOptions.length) % slashOptions.length); } else if (showSlashAutocomplete && e.keyCode === 13) { // Enter e.preventDefault(); if (slashOptions[slashCursorIndex]) { insertSlashCommand(slashOptions[slashCursorIndex]); } } else if (e.keyCode === 27) { // Escape e.preventDefault(); setShowMentionAutocomplete(false); setShowSlashAutocomplete(false); } }; // Insert selected mention const insertMention = (option) => { const value = input; const inputNode = inputRef.current?.textarea || inputRef.current; const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length; const textBeforeCursor = value.substring(0, cursorPosition); const mentionStart = textBeforeCursor.lastIndexOf('@'); const beforeMention = value.substring(0, mentionStart); const afterMention = value.substring(cursorPosition); const newValue = beforeMention + option.label + ' ' + afterMention; setInput(newValue); setShowMentionAutocomplete(false); setMentionOptions([]); // Focus back on input setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); } }, 0); }; const insertSlashCommand = (option) => { const value = input; const inputNode = inputRef.current?.textarea || inputRef.current; const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length; const textBeforeCursor = value.substring(0, cursorPosition); const slashStart = textBeforeCursor.lastIndexOf('/'); const beforeSlash = value.substring(0, slashStart); const afterSlash = value.substring(cursorPosition); const newValue = beforeSlash + option.insertText + afterSlash; setInput(newValue); setShowSlashAutocomplete(false); setSlashOptions([]); if (option.insertText.endsWith('@')) { const mentionOptionsList = getMentionOptions(''); setMentionQuery(''); setMentionOptions(mentionOptionsList); setShowMentionAutocomplete(mentionOptionsList.length > 0); setMentionCursorIndex(0); } setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); } }, 0); }; const sendMessage = async () => { if (!input.trim() || isLoading) { return; } const userMessage = input.trim(); const parsedCommand = parseInsertCommand(userMessage); const commandMessage = parsedCommand ? parsedCommand.message : userMessage; const mentionTokens = extractMentionsFromText(commandMessage); const hasMentions = mentionTokens.length > 0; const refineableBlocks = getRefineableBlocks(); const shouldShowPlan = agentMode === 'planning'; const generationLabel = agentMode === 'planning' ? 'Creating outline...' : 'Generating article...'; const reformatCommand = /^\s*(?:\/)?reformat\b/i; if (parsedCommand) { setIsLoading(true); setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'refining', message: 'Preparing insertion...', timestamp: new Date() }]); await insertRefinementBlock(parsedCommand.mode, commandMessage, mentionTokens, userMessage); setIsLoading(false); return; } if (reformatCommand.test(userMessage)) { setInput(''); setMessages([...messages, { role: 'user', content: userMessage }]); const targetIds = hasMentions ? resolveBlockMentions(mentionTokens) : getRefineableBlocks().map((block) => block.clientId); const allBlocks = select('core/block-editor').getBlocks(); const blocksToReformat = allBlocks.filter((block) => targetIds.includes(block.clientId)); await reformatBlocks(blocksToReformat, userMessage); return; } if (agentMode === 'planning' && !hasMentions && currentPlanRef.current) { setInput(''); setMessages([...messages, { role: 'user', content: userMessage }]); await revisePlanFromPrompt(userMessage); return; } if (agentMode === 'chat' && !hasMentions) { setInput(''); setMessages([...messages, { role: 'user', content: userMessage }]); setIsLoading(true); // Store for retry lastChatRequestRef.current = { message: userMessage }; try { const chatHistory = messages .filter((m) => m.role === 'user' || m.role === 'assistant') .map((m) => ({ role: m.role, content: m.content })); const response = await fetch(wpAgenticWriter.apiUrl + '/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ messages: [...chatHistory, { role: 'user', content: userMessage }], postId: postId, type: 'chat', stream: true, postConfig: postConfig, }), }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || 'Failed to chat'); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let streamBuffer = ''; streamTargetRef.current = null; while (true) { const { done, value } = await reader.read(); if (done) break; streamBuffer += decoder.decode(value, { stream: true }); const lines = streamBuffer.split('\n'); streamBuffer = lines.pop() || ''; for (const line of lines) { if (!line.startsWith('data: ')) { continue; } try { const data = JSON.parse(line.slice(6)); if (data.type === 'error') { throw new Error(data.message || 'Failed to chat'); } if (data.type === 'conversational' || data.type === 'conversational_stream') { const cleanContent = (data.content || '').trim(); if (!cleanContent) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (data.type === 'conversational') { setMessages(prev => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; const lastMessage = newMessages[lastIdx]; if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === cleanContent) { return newMessages; } newMessages.push({ role: 'assistant', content: cleanContent }); return newMessages; }); } else { setMessages(prev => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; } else { newMessages.push({ role: 'assistant', content: cleanContent }); } return newMessages; }); } } else if (data.type === 'complete' && data.totalCost) { setCost({ ...cost, session: cost.session + data.totalCost }); } } catch (parseError) { console.error('Failed to parse streaming data:', line, parseError); } } } } catch (error) { const errorMsg = error.message || 'Failed to chat'; const isRateLimit = errorMsg.includes('429') || errorMsg.toLowerCase().includes('rate limit'); setMessages(prev => [...prev, { role: 'system', type: 'error', content: isRateLimit ? 'Rate limit exceeded. Please wait a moment and try again.' : 'Error: ' + errorMsg, canRetry: true, retryType: 'chat' }]); } setIsLoading(false); return; } if (!hasMentions && refineableBlocks.length > 0) { // Content exists - run clarity check before full-article refinement const targetedBlocks = getTargetedRefinementBlocks(userMessage); const matchedSection = !targetedBlocks ? findBestPlanSectionMatch(userMessage) : null; const matchedSectionBlocks = matchedSection ? sectionBlocksRef.current[matchedSection.id] || [] : []; setInput(''); setMessages([...messages, { role: 'user', content: userMessage }]); if (matchedSectionBlocks.length > 0) { setMessages(prev => [...prev, { role: 'assistant', content: `Targeting section: ${matchedSection.heading || matchedSection.title || 'Selected section'} (${matchedSectionBlocks.length} block(s)).` }]); } setIsLoading(true); setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'checking', message: matchedSection ? `Analyzing request (targeting: ${matchedSection.heading || matchedSection.title || 'section'})...` : 'Analyzing request...', timestamp: new Date() }]); const fallbackBlocks = refineableBlocks.map((block) => block.clientId); await handleChatRefinement( userMessage, (targetedBlocks && targetedBlocks.length > 0) ? targetedBlocks : (matchedSectionBlocks.length > 0 ? matchedSectionBlocks : fallbackBlocks), { skipUserMessage: true } ); return; } if (!hasMentions) { // No mentions - check clarity first before article generation setInput(''); setMessages([...messages, { role: 'user', content: userMessage }]); setIsLoading(true); // Check clarity first setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'checking', message: 'Analyzing request...', timestamp: new Date() }]); // First try clarity check try { const clarityResponse = await fetch(wpAgenticWriter.apiUrl + '/check-clarity', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ topic: userMessage, answers: [], postId: postId, mode: 'generation', postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), }), }); if (clarityResponse.ok) { const clarityData = await clarityResponse.json(); const clarityResult = clarityData.result; // Store detected language for article generation if (clarityResult.detected_language) { setDetectedLanguage(clarityResult.detected_language); } if (!clarityResult.is_clear && clarityResult.questions && clarityResult.questions.length > 0) { // Need clarification - show quiz setQuestions(clarityResult.questions); setInClarification(true); setCurrentQuestionIndex(0); setAnswers([]); setIsLoading(false); // Update timeline setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'waiting', message: 'Waiting for clarification...' }; } return newMessages; }); return; } } // If clarity check fails, proceed with generation anyway } catch (clarityError) { console.warn('Clarity check failed, proceeding with generation:', clarityError); // Continue to article generation } // Clear enough - proceed with article generation // Update timeline setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'starting', message: generationLabel }; } return newMessages; }); // Now call generate-plan try { const response = await fetch(wpAgenticWriter.apiUrl + '/generate-plan', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ topic: userMessage, context: '', postId: postId, answers: [], autoExecute: agentMode !== 'planning', stream: true, articleLength: postConfig.article_length, detectedLanguage: detectedLanguage, postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), }), }); if (!response.ok) { const error = await response.json(); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to generate article'), canRetry: true }]); setIsLoading(false); return; } // Handle streaming response streamTargetRef.current = null; const reader = response.body.getReader(); const decoder = new TextDecoder(); // Add timeout to detect hanging responses const timeout = setTimeout(() => { if (isLoading) { console.error('Generation timeout - no response received'); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Request timeout. The AI is taking too long to respond. Please try again.', canRetry: true }]); setIsLoading(false); reader.cancel(); } }, 120000); // 2 minute timeout while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.type === 'plan') { setCost({ ...cost, session: cost.session + data.cost }); if (shouldShowPlan && data.plan) { updateOrCreatePlanMessage(data.plan); } } else if (data.type === 'title_update') { dispatch('core/editor').editPost({ title: data.title }); } else if (data.type === 'status') { if (data.status === 'complete') { continue; } // Update timeline setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon }; } return newMessages; }); } else if (data.type === 'conversational' || data.type === 'conversational_stream') { // Remove article marker and clean content const cleanContent = (data.content || '') .replace(/~~~ARTICLE~+/g, '') .replace(/~~~ARTICLE~~~[\r\n]*/g, '') .trim(); // Skip if content is empty after cleaning if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === 'timeline') { updateOrCreateTimelineEntry(cleanContent); } else { // This is actual conversational content - add as chat bubble if (data.type === 'conversational') { setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); } else { setMessages(prev => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; } else { newMessages.push({ role: 'assistant', content: cleanContent }); } return newMessages; }); } } } else if (data.type === 'block') { const { insertBlocks } = dispatch('core/block-editor'); let newBlock; if (data.block.blockName === 'core/paragraph') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); } else if (data.block.blockName === 'core/heading') { const level = data.block.attrs?.level || 2; const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); } else if (data.block.blockName === 'core/list') { const listItems = (data.block.innerBlocks || []).map(item => { 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); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); } else if (data.block.blockName === 'core/image') { newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); } if (newBlock) { insertBlocks(newBlock); } } else if (data.type === 'complete') { clearTimeout(timeout); setCost({ ...cost, session: cost.session + data.totalCost }); // Update timeline to complete setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'complete', message: agentMode === 'planning' ? 'Outline ready.' : 'Article generated successfully!' }; } return newMessages; }); setIsLoading(false); } else if (data.type === 'error') { clearTimeout(timeout); setMessages(prev => [...prev, { role: 'system', type: 'error', content: data.message || 'An error occurred during article generation', canRetry: true }]); setIsLoading(false); } } catch (parseError) { console.error('Failed to parse streaming data:', line, parseError); } } } } // Clear timeout when streaming completes normally clearTimeout(timeout); } catch (error) { clearTimeout(timeout); console.error('Article generation error:', error); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to generate article'), canRetry: true }]); setIsLoading(false); } return; } // Has mentions - check if mentioned blocks exist let blocksToRefine = []; if (hasMentions) { blocksToRefine = resolveBlockMentions(mentionTokens); } if (blocksToRefine.length > 0) { // Blocks exist - this is a refinement request setInput(''); await handleChatRefinement(userMessage); return; } if (refineableBlocks.length > 0) { if (userMessage.includes('@')) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'No valid blocks found to refine. Try @this, @previous, @next, @all, or @paragraph-1.' }]); setIsLoading(false); return; } // No valid mentions, but content exists - refine the whole article setInput(''); await handleChatRefinement(userMessage, refineableBlocks.map((block) => block.clientId)); return; } // Blocks don't exist yet - this is article generation // User is specifying structure for new article setInput(''); setMessages([...messages, { role: 'user', content: userMessage }]); setIsLoading(true); // Add loading timeline entry setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'starting', message: 'Initializing...', timestamp: new Date() }]); try { const response = await fetch(wpAgenticWriter.apiUrl + '/generate-plan', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ topic: userMessage, context: '', postId: postId, answers: [], autoExecute: agentMode !== 'planning', stream: true, articleLength: postConfig.article_length, postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), }), }); if (!response.ok) { const error = await response.json(); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to generate article'), canRetry: true }]); setIsLoading(false); return; } // Handle streaming response const reader = response.body.getReader(); const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.type === 'plan') { setCost({ ...cost, session: cost.session + data.cost }); if (agentMode === 'planning' && data.plan) { updateOrCreatePlanMessage(data.plan); } } else if (data.type === 'title_update') { dispatch('core/editor').editPost({ title: data.title }); } else if (data.type === 'status') { if (data.status === 'complete') { continue; } // Update timeline setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon }; } return newMessages; }); } else if (data.type === 'conversational' || data.type === 'conversational_stream') { const cleanContent = (data.content || '') .replace(/~~~ARTICLE~+/g, '') .replace(/~~~ARTICLE~~~[\r\n]*/g, '') .trim(); if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === 'timeline') { updateOrCreateTimelineEntry(cleanContent); } else if (data.type === 'conversational') { setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); } else { setMessages(prev => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; } else { newMessages.push({ role: 'assistant', content: cleanContent }); } return newMessages; }); } } else if (data.type === 'block') { const { insertBlocks } = dispatch('core/block-editor'); let newBlock; if (data.block.blockName === 'core/paragraph') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); } else if (data.block.blockName === 'core/heading') { const level = data.block.attrs?.level || 2; const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); } else if (data.block.blockName === 'core/list') { const listItems = (data.block.innerBlocks || []).map(item => { 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); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); } else if (data.block.blockName === 'core/image') { newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); } else { const parsed = wp.blocks.parse(data.block.innerHTML); newBlock = parsed && parsed.length > 0 ? parsed[0] : null; } if (newBlock) { insertBlocks(newBlock); } } else if (data.type === 'complete') { setCost({ ...cost, session: cost.session + data.totalCost }); // Update timeline to complete setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'complete', message: agentMode === 'planning' ? 'Outline ready.' : 'Article generation complete!' }; } return newMessages; }); // Trigger duplicate cleanup setTimeout(() => { const allBlocks = select('core/block-editor').getBlocks(); const cleanedBlocks = removeDuplicateHeadings(allBlocks); if (cleanedBlocks.length < allBlocks.length) { dispatch('core/block-editor').resetBlocks(cleanedBlocks); } }, 500); } else if (data.type === 'error') { throw new Error(data.message); } } catch (parseError) { console.error('Failed to parse streaming data:', line, parseError); } } } } setTimeout(() => { setIsLoading(false); }, 1500); } catch (error) { setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + error.message }]); setIsLoading(false); } }; // Submit answers and continue generation. const submitAnswers = async () => { if (isLoading) { return; } // Process config answers and update post config // Handle language selection if (answers.config_language) { let languageValue = answers.config_language; // Handle custom language input if (languageValue === '__custom__' && answers.config_language_custom) { languageValue = answers.config_language_custom.toLowerCase().trim(); } if (languageValue && languageValue !== '__skipped__') { updatePostConfig('language', languageValue); } } // Handle other config settings if (answers.config_all) { try { const configData = JSON.parse(answers.config_all); // Apply config to post config if (configData.web_search !== undefined) { updatePostConfig('web_search', configData.web_search); } if (configData.seo !== undefined) { updatePostConfig('seo_enabled', configData.seo); } if (configData.focus_keyword) { updatePostConfig('seo_focus_keyword', configData.focus_keyword); } if (configData.secondary_keywords) { updatePostConfig('seo_secondary_keywords', configData.secondary_keywords); } } catch (e) { console.error('Failed to parse config answers:', e); } } if (clarificationMode === 'refinement' && pendingRefinement) { setInClarification(false); const clarificationContext = formatClarificationContext(questions, answers); const refinedMessage = `${pendingRefinement.message}${clarificationContext}`; const blocks = pendingRefinement.blocks || []; setPendingRefinement(null); setClarificationMode('generation'); await handleChatRefinement(refinedMessage, blocks, { skipUserMessage: true }); return; } setIsLoading(true); // Exit quiz mode and return to chat immediately so user can see progress setInClarification(false); // Add timeline entry showing generation is starting setMessages(prev => [...deactivateActiveTimelineEntries(prev), { role: 'system', type: 'timeline', status: 'starting', message: agentMode === 'planning' ? 'Creating outline...' : 'Generating article...', timestamp: new Date() }]); try { const topic = messages.map((m) => m.content).join('\n'); const response = await fetch(wpAgenticWriter.apiUrl + '/generate-plan', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': wpAgenticWriter.nonce, }, body: JSON.stringify({ topic: topic, context: '', postId: postId, clarificationAnswers: answers, autoExecute: agentMode !== 'planning', stream: true, articleLength: postConfig.article_length, detectedLanguage: detectedLanguage, postConfig: postConfig, chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10), }), }); if (!response.ok) { const error = await response.json(); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to generate plan'), canRetry: true, retryType: 'generation' }]); setIsLoading(false); return; } // Handle streaming response (similar to sendMessage) streamTargetRef.current = null; const reader = response.body.getReader(); const decoder = new TextDecoder(); // Add timeout to detect hanging responses const timeout = setTimeout(() => { if (isLoading) { console.error('Generation timeout - no response received'); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Request timeout. The AI is taking too long to respond. Please try again.', canRetry: true, retryType: 'generation' }]); setIsLoading(false); reader.cancel(); } }, 120000); // 2 minute timeout while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value, { stream: true }); const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith('data: ')) { try { const data = JSON.parse(line.slice(6)); if (data.type === 'plan') { setCost({ ...cost, session: cost.session + data.cost }); if (agentMode === 'planning' && data.plan) { updateOrCreatePlanMessage(data.plan); } } else if (data.type === 'title_update') { dispatch('core/editor').editPost({ title: data.title }); } else if (data.type === 'status') { if (data.status === 'complete') { continue; } // Update timeline setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon }; } return newMessages; }); } else if (data.type === 'conversational' || data.type === 'conversational_stream') { // Remove article marker and clean content const cleanContent = (data.content || '') .replace(/~~~ARTICLE~+/g, '') .replace(/~~~ARTICLE~~~[\r\n]*/g, '') .trim(); // Skip if content is empty after cleaning if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === 'timeline') { updateOrCreateTimelineEntry(cleanContent); } else { // This is actual conversational content - add as chat bubble if (data.type === 'conversational') { setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]); } else { setMessages(prev => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent }; } else { newMessages.push({ role: 'assistant', content: cleanContent }); } return newMessages; }); } } } else if (data.type === 'block') { // Insert blocks (same as above) const { insertBlocks } = dispatch('core/block-editor'); let newBlock; if (data.block.blockName === 'core/paragraph') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/paragraph', { content: content }); } else if (data.block.blockName === 'core/heading') { const level = data.block.attrs?.level || 2; const content = data.block.innerHTML?.match(/(.*?)<\/h[1-6]>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content }); } else if (data.block.blockName === 'core/list') { const listItems = (data.block.innerBlocks || []).map(item => { 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); } else if (data.block.blockName === 'core/quote') { const content = data.block.innerHTML?.match(/

    (.*?)<\/p>/)?.[1] || ''; newBlock = wp.blocks.createBlock('core/quote', { value: content }); } else if (data.block.blockName === 'core/image') { newBlock = wp.blocks.createBlock('core/image', data.block.attrs || {}); } if (newBlock) { insertBlocks(newBlock); } } else if (data.type === 'complete') { clearTimeout(timeout); setCost({ ...cost, session: cost.session + data.totalCost }); // Update timeline to complete setMessages(prev => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: 'complete', message: agentMode === 'planning' ? 'Outline ready.' : 'Article generated successfully!' }; } return newMessages; }); setIsLoading(false); } else if (data.type === 'error') { clearTimeout(timeout); setMessages(prev => [...prev, { role: 'system', type: 'error', content: data.message || 'An error occurred during article generation', canRetry: true, retryType: 'generation' }]); setIsLoading(false); } } catch (parseError) { console.error('Failed to parse streaming data:', line, parseError); } } } // Clear timeout when streaming completes normally clearTimeout(timeout); } } catch (error) { clearTimeout(timeout); console.error('Article generation error:', error); setMessages(prev => [...prev, { role: 'system', type: 'error', content: 'Error: ' + (error.message || 'Failed to generate article'), canRetry: true, retryType: 'generation' }]); setIsLoading(false); } }; // Render clarification quiz UI. const renderClarification = () => { if (!inClarification || questions.length === 0) { return null; } const currentQuestion = questions[currentQuestionIndex]; const currentAnswer = answers[currentQuestion.id] || ''; // Helper to render single choice options const renderSingleChoice = () => { const customInputKey = `${currentQuestion.id}_custom`; const customValue = answers[customInputKey] || ''; const isCustomSelected = currentAnswer === '__custom__'; return wp.element.createElement('div', { className: 'wpaw-answer-options' }, currentQuestion.options.map((option, idx) => { const isSelected = currentAnswer === option.value; return wp.element.createElement('label', { key: idx }, wp.element.createElement('input', { type: 'radio', name: currentQuestion.id, checked: isSelected, onChange: () => { const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = option.value; setAnswers(newAnswers); }, }), wp.element.createElement('span', null, option.value) ); }), // Add custom text input option wp.element.createElement('div', { className: 'wpaw-custom-answer-wrapper', key: 'custom' }, wp.element.createElement('label', null, wp.element.createElement('input', { type: 'radio', name: currentQuestion.id, checked: isCustomSelected, onChange: () => { const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = '__custom__'; setAnswers(newAnswers); }, }), wp.element.createElement('span', null, 'Other (specify):') ), isCustomSelected && wp.element.createElement('input', { type: 'text', className: 'wpaw-custom-text-input', placeholder: 'Type your answer here...', value: customValue, onChange: (e) => { const newAnswers = { ...answers }; newAnswers[customInputKey] = e.target.value; setAnswers(newAnswers); }, autoFocus: true }) ) ); }; // Helper to render multiple choice options const renderMultipleChoice = () => { const selectedValues = currentAnswer ? currentAnswer.split(', ') : []; return wp.element.createElement('div', { className: 'wpaw-answer-options' }, currentQuestion.options.map((option, idx) => { const isSelected = selectedValues.includes(option.value); return wp.element.createElement('label', { key: idx }, wp.element.createElement('input', { type: 'checkbox', checked: isSelected, onChange: () => { const newAnswers = { ...answers }; let newSelected = isSelected ? selectedValues.filter(v => v !== option.value) : [...selectedValues, option.value]; newAnswers[currentQuestion.id] = newSelected.join(', '); setAnswers(newAnswers); }, }), wp.element.createElement('span', null, option.value) ); }) ); }; // Helper to render open text textarea const renderOpenText = () => { return wp.element.createElement('div', { className: 'wpaw-answer-options' }, wp.element.createElement(TextareaControl, { placeholder: currentQuestion.placeholder || 'Type your answer here...', value: currentAnswer, onChange: (value) => { const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = value; setAnswers(newAnswers); }, rows: 4, maxLength: currentQuestion.max_length || 500, }) ); }; // Helper to render config form (consolidated config page) const renderConfigForm = () => { // Initialize with defaults if no answer exists let configData = {}; if (currentAnswer) { try { configData = JSON.parse(currentAnswer); } catch (e) { configData = {}; } } // Set defaults from field definitions if not already set const fields = currentQuestion.fields || []; fields.forEach(field => { if (configData[field.id] === undefined && field.default !== undefined) { configData[field.id] = field.default; } }); // Initialize answer with defaults on first render if (!currentAnswer && Object.keys(configData).length > 0) { const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = JSON.stringify(configData); setAnswers(newAnswers); } return wp.element.createElement('div', { className: 'wpaw-config-form' }, fields.map((field, idx) => { const fieldValue = configData[field.id] !== undefined ? configData[field.id] : field.default; const isConditional = field.conditional && !configData[field.conditional]; if (isConditional) { return null; } return wp.element.createElement('div', { key: idx, className: 'wpaw-config-field' }, field.type === 'toggle' ? wp.element.createElement(React.Fragment, null, wp.element.createElement('label', { className: 'wpaw-config-label' }, wp.element.createElement('span', { className: 'wpaw-config-label-text' }, field.label), field.description && wp.element.createElement('span', { className: 'wpaw-config-description' }, field.description) ), wp.element.createElement('label', { className: 'wpaw-config-toggle' }, wp.element.createElement('input', { type: 'checkbox', checked: fieldValue || false, onChange: (e) => { const newConfig = { ...configData }; newConfig[field.id] = e.target.checked; const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = JSON.stringify(newConfig); setAnswers(newAnswers); } }), wp.element.createElement('span', { className: 'wpaw-toggle-slider' }) ) ) : wp.element.createElement(React.Fragment, null, wp.element.createElement('label', { className: 'wpaw-config-label' }, wp.element.createElement('span', { className: 'wpaw-config-label-text' }, field.label) ), wp.element.createElement('input', { type: 'text', className: 'wpaw-config-text-input', placeholder: field.placeholder || '', value: fieldValue || '', maxLength: field.max_length || 200, onChange: (e) => { const newConfig = { ...configData }; newConfig[field.id] = e.target.value; const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = JSON.stringify(newConfig); setAnswers(newAnswers); } }) ) ); }) ); }; // Render appropriate input type based on question type let answerInput; switch (currentQuestion.type) { case 'single_choice': answerInput = renderSingleChoice(); break; case 'multiple_choice': answerInput = renderMultipleChoice(); break; case 'open_text': answerInput = renderOpenText(); break; case 'config_form': answerInput = renderConfigForm(); break; default: answerInput = renderSingleChoice(); } return wp.element.createElement('div', { className: 'wpaw-clarification-quiz dark-theme' }, wp.element.createElement('div', { className: 'wpaw-quiz-header' }, wp.element.createElement('h3', null, ' Clarification Questions'), wp.element.createElement('div', { className: 'wpaw-progress-bar' }, wp.element.createElement('div', { className: 'wpaw-progress-fill', style: { width: ((currentQuestionIndex + 1) / questions.length * 100) + '%' } }) ), wp.element.createElement('span', null, `${currentQuestionIndex + 1} of ${questions.length}`) ), wp.element.createElement('div', { className: 'wpaw-question-card' }, wp.element.createElement('h4', null, currentQuestion.question), answerInput, wp.element.createElement('div', { className: 'wpaw-quiz-actions' }, // Previous button currentQuestionIndex > 0 && wp.element.createElement(Button, { isSecondary: true, onClick: () => setCurrentQuestionIndex(currentQuestionIndex - 1), disabled: isLoading, }, 'Previous'), // Skip button for optional questions wp.element.createElement(Button, { isSecondary: true, onClick: () => { const newAnswers = { ...answers }; newAnswers[currentQuestion.id] = '__skipped__'; setAnswers(newAnswers); if (currentQuestionIndex === questions.length - 1) { submitAnswers(); } else { setCurrentQuestionIndex(currentQuestionIndex + 1); } }, disabled: isLoading, }, 'Skip'), // Continue/Finish button wp.element.createElement(Button, { isPrimary: true, onClick: () => { if (currentQuestionIndex === questions.length - 1) { submitAnswers(); } else { setCurrentQuestionIndex(currentQuestionIndex + 1); } }, disabled: isLoading || (!currentAnswer.trim() && currentAnswer !== '__custom__'), }, currentQuestionIndex === questions.length - 1 ? 'Finish' : 'Next') ) ) ); }; // Render chat messages with timeline const renderMessages = () => { const normalizeMessageContent = (content) => { if (content === null || content === undefined) { return ''; } if (typeof content === 'string' || typeof content === 'number') { return String(content); } return JSON.stringify(content); }; const escapeHtml = (value) => { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); }; const inlineMarkdownToHtml = (text) => { let html = escapeHtml(text); html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => ( `${label}` )); html = html.replace(/`([^`]+)`/g, (match, code) => `${escapeHtml(code)}`); html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); html = html.replace(/__([^_]+)__/g, '$1'); html = html.replace(/\*([^*]+)\*/g, '$1'); html = html.replace(/_([^_]+)_/g, '$1'); return html; }; const markdownToHtml = (markdown) => { const raw = normalizeMessageContent(markdown); if (!raw) { return ''; } if (window.markdownit && window.DOMPurify) { if (!markdownRendererRef.current) { const renderer = window.markdownit({ html: false, linkify: true, breaks: false, }); if (window.markdownitTaskLists) { renderer.use(window.markdownitTaskLists, { enabled: true, label: true, labelAfter: true }); } const defaultLinkOpen = renderer.renderer.rules.link_open || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; renderer.renderer.rules.link_open = function (tokens, idx, options, env, self) { const token = tokens[idx]; const targetIndex = token.attrIndex('target'); if (targetIndex < 0) { token.attrPush(['target', '_blank']); } else { token.attrs[targetIndex][1] = '_blank'; } const relIndex = token.attrIndex('rel'); if (relIndex < 0) { token.attrPush(['rel', 'noopener noreferrer']); } else { token.attrs[relIndex][1] = 'noopener noreferrer'; } return defaultLinkOpen(tokens, idx, options, env, self); }; markdownRendererRef.current = renderer; } const rendered = markdownRendererRef.current.render(raw); return window.DOMPurify.sanitize(rendered, { USE_PROFILES: { html: true }, ADD_TAGS: ['input', 'label'], ADD_ATTR: ['type', 'checked', 'disabled', 'class'], }); } const codeBlocks = []; let text = raw.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { const safeLang = lang ? ` class="language-${escapeHtml(lang)}"` : ''; const index = codeBlocks.length; codeBlocks.push(`

    ${escapeHtml(code)}
    `); return `@@CODEBLOCK${index}@@`; }); const lines = text.split(/\r?\n/); let html = ''; let paragraph = []; let list = null; let detailBreak = false; let lastLineWasListItem = false; const flushParagraph = () => { if (paragraph.length) { html += `

    ${inlineMarkdownToHtml(paragraph.join(' '))}

    `; paragraph = []; } }; const flushList = () => { if (list) { const items = list.items.map((item) => { const details = item.details && item.details.length > 0 ? item.details.map((detail) => `

    ${inlineMarkdownToHtml(detail)}

    `).join('') : ''; const children = item.children && item.children.length > 0 ? `` : ''; return `
  • ${inlineMarkdownToHtml(item.content)}${details}${children}
  • `; }).join(''); html += `<${list.type}>${items}`; list = null; } }; const addListItem = (targetList, value) => { targetList.items.push({ content: value, children: [], details: [] }); lastLineWasListItem = true; }; const addDetailToLastItem = (targetList, value, newParagraph) => { const lastItem = targetList.items[targetList.items.length - 1]; if (!lastItem) { return; } if (newParagraph || lastItem.details.length === 0) { lastItem.details.push(value); } else { lastItem.details[lastItem.details.length - 1] += ` ${value}`; } lastLineWasListItem = false; }; const getListType = (value) => { if (/^\d+\.\s+/.test(value)) { return 'ol'; } if (/^[-*+]\s+/.test(value)) { return 'ul'; } return null; }; for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (trimmed === '') { let nextIndex = i + 1; while (nextIndex < lines.length && lines[nextIndex].trim() === '') { nextIndex += 1; } const nextLine = nextIndex < lines.length ? lines[nextIndex].trim() : ''; const nextType = getListType(nextLine); if (list && nextType && nextType === list.type) { continue; } if ( list && list.type === 'ol' && nextLine && !nextType && !nextLine.startsWith('@@CODEBLOCK') && ! /^(#{1,6})\s+/.test(nextLine) ) { detailBreak = true; lastLineWasListItem = false; continue; } flushList(); flushParagraph(); lastLineWasListItem = false; continue; } if (trimmed.startsWith('@@CODEBLOCK')) { flushList(); flushParagraph(); html += trimmed; lastLineWasListItem = false; continue; } const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); if (headingMatch) { flushList(); flushParagraph(); const level = headingMatch[1].length; html += `${inlineMarkdownToHtml(headingMatch[2])}`; lastLineWasListItem = false; continue; } const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/); const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/); if (unorderedMatch || orderedMatch) { flushParagraph(); detailBreak = false; const type = orderedMatch ? 'ol' : 'ul'; let value = (orderedMatch ? orderedMatch[1] : unorderedMatch[1]) || ''; if (orderedMatch) { value = value.replace(/^\d+\.\s+/, ''); } if (!orderedMatch && list && list.type === 'ol' && list.items.length > 0) { list.items[list.items.length - 1].children.push(value); continue; } if (!list || list.type !== type) { flushList(); list = { type, items: [] }; } addListItem(list, value); continue; } if (list && list.type === 'ol' && (lastLineWasListItem || detailBreak)) { addDetailToLastItem(list, trimmed, detailBreak); detailBreak = false; continue; } if (list) { flushList(); } paragraph.push(trimmed); lastLineWasListItem = false; } flushList(); flushParagraph(); codeBlocks.forEach((block, index) => { html = html.replace(`@@CODEBLOCK${index}@@`, block); }); return html; }; const renderMessageContent = (content, allowMarkdown) => { if (!allowMarkdown) { return normalizeMessageContent(content); } return wp.element.createElement(RawHTML, null, markdownToHtml(content)); }; const lastActiveTimelineIndex = findLastActiveTimelineIndex(messages); const groups = []; let currentAiGroup = null; messages.forEach((message, index) => { if (message.role === 'user') { groups.push({ type: 'user', message, key: `user-${index}` }); currentAiGroup = null; return; } if (!currentAiGroup) { currentAiGroup = { type: 'ai', items: [], key: `ai-${index}` }; groups.push(currentAiGroup); } currentAiGroup.items.push({ message, index }); }); return groups.map((group, groupIndex) => { if (group.type === 'user') { return wp.element.createElement('div', { key: group.key, className: 'wpaw-message wpaw-message-user', }, wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(group.message.content, false)) ); } const isLastGroup = groupIndex === groups.length - 1; let streamingLabel = 'Streaming...'; for (let i = group.items.length - 1; i >= 0; i--) { const item = group.items[i].message; if (item.type === 'timeline' && item.status) { if (item.status === 'checking') { streamingLabel = 'Analyzing...'; } else if (item.status === 'planning' || item.status === 'plan_complete') { streamingLabel = 'Planning...'; } else if (item.status === 'writing' || item.status === 'writing_section') { streamingLabel = 'Writing...'; } else if (item.status === 'refining') { streamingLabel = 'Refining...'; } else { streamingLabel = 'Streaming...'; } break; } } return wp.element.createElement('div', { key: group.key, className: 'wpaw-ai-response', }, group.items.map((item, itemIndex) => { const message = item.message; const index = item.index; const isLastItem = itemIndex === group.items.length - 1; if (message.type === 'timeline') { const statusClass = message.status === 'complete' ? 'complete' : message.status === 'inactive' ? 'inactive' : 'active'; const showProcessing = isLoading && message.status === 'refining'; const elapsedTime = message.status === 'complete' && message.timestamp && message.completedAt ? ((new Date(message.completedAt) - new Date(message.timestamp)) / 1000).toFixed(1) + 's' : null; return wp.element.createElement('div', { key: `timeline-${index}`, className: 'wpaw-ai-item wpaw-timeline-entry ' + statusClass + (index === lastActiveTimelineIndex ? ' is-current' : ''), }, wp.element.createElement('div', { className: 'wpaw-timeline-dot', 'aria-hidden': 'true' }), wp.element.createElement('div', { className: 'wpaw-timeline-content' }, wp.element.createElement('div', { className: 'wpaw-timeline-message' }, normalizeMessageContent(message.message)), message.status === 'complete' && wp.element.createElement('div', { className: 'wpaw-timeline-complete' }, '✓ Complete', elapsedTime && wp.element.createElement('span', { className: 'wpaw-timeline-elapsed' }, ` (${elapsedTime})`) ), showProcessing && wp.element.createElement('div', { className: 'wpaw-processing-indicator' }, wp.element.createElement('span', { className: 'wpaw-dots-loader' }), wp.element.createElement('span', null, 'Processing updates…') ), !showProcessing && isLoading && isLastGroup && isLastItem && wp.element.createElement('div', { className: 'wpaw-streaming-indicator', }, streamingLabel) ) ); } if (message.type === 'plan') { const plan = ensurePlanTasks(message.plan); const sections = Array.isArray(plan?.sections) ? plan.sections : []; const getSectionSummary = (section) => { if (section.description) { return section.description; } if (Array.isArray(section.content) && section.content.length > 0) { const firstItem = section.content.find((item) => item && item.content); return firstItem ? firstItem.content : ''; } return ''; }; const pendingCount = sections.filter((section) => section.status !== 'done').length; const buttonLabel = pendingCount ? `Write ${pendingCount} Pending` : 'Write Article'; // Build config summary const configSummary = []; const languageLabel = postConfig.language === 'auto' ? 'Auto-detect' : postConfig.language.charAt(0).toUpperCase() + postConfig.language.slice(1); configSummary.push(`🌍 Language: ${languageLabel}`); const lengthLabels = { short: 'Short (~800 words)', medium: 'Medium (~1500 words)', long: 'Long (~2500 words)' }; configSummary.push(`📏 Length: ${lengthLabels[postConfig.article_length] || 'Medium'}`); if (postConfig.audience) { configSummary.push(`👥 Audience: ${postConfig.audience}`); } if (postConfig.web_search) { configSummary.push('🔍 Web Search: Enabled'); } if (postConfig.seo_enabled) { const seoDetails = []; if (postConfig.seo_focus_keyword) { seoDetails.push(`Focus: "${postConfig.seo_focus_keyword}"`); } if (postConfig.seo_secondary_keywords) { seoDetails.push(`Secondary: "${postConfig.seo_secondary_keywords}"`); } configSummary.push(`📊 SEO: Enabled${seoDetails.length ? ' (' + seoDetails.join(', ') + ')' : ''}`); } return wp.element.createElement('div', { key: `plan-${index}`, className: 'wpaw-ai-item wpaw-plan-card', }, if (message.type === 'edit_plan') { const plan = message.plan || pendingEditPlan; const isPlanActive = Boolean(pendingEditPlan) && plan === pendingEditPlan; const actions = normalizePlanActions(plan); const allBlocks = select('core/block-editor').getBlocks(); const existingIds = new Set(allBlocks.map((block) => block.clientId)); const previewActions = actions.filter((action) => { if (action.action === 'keep') { return false; } if (action.blockId && !existingIds.has(action.blockId)) { return false; } return true; }); const actionCount = previewActions.length; const summary = plan?.summary || `Proposed changes: ${actionCount}`; const previewItems = previewActions.map((action, actionIndex) => ( buildPlanPreviewItem(action, actionIndex) )); return wp.element.createElement('div', { key: `plan-${index}`, className: 'wpaw-ai-item wpaw-edit-plan', }, wp.element.createElement('div', { className: 'wpaw-edit-plan-title' }, 'Proposed Changes'), wp.element.createElement('div', { className: 'wpaw-edit-plan-summary' }, summary), previewItems.length > 0 && wp.element.createElement('div', { className: 'wpaw-edit-plan-preview-label' }, 'Apply preview'), previewItems.length > 0 && wp.element.createElement('ol', { className: 'wpaw-edit-plan-list' }, previewItems.map((item, itemIndex) => wp.element.createElement('li', { key: `plan-action-${itemIndex}`, className: 'wpaw-edit-plan-item', }, wp.element.createElement('div', { className: 'wpaw-edit-plan-item-title' }, item.title), item.target && wp.element.createElement('button', { type: 'button', className: 'wpaw-edit-plan-item-target', disabled: !isPlanActive, onClick: () => { if (!isPlanActive || !item.blockId) { return; } dispatch('core/block-editor').selectBlock(item.blockId); const targetNode = document.querySelector(`[data-block="${item.blockId}"]`); if (targetNode) { targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }, }, `${item.targetLabel} ${item.target}`), item.before && wp.element.createElement('div', { className: 'wpaw-edit-plan-item-before' }, `Before ${item.before}`), item.after && wp.element.createElement('div', { className: 'wpaw-edit-plan-item-after' }, `Add ${item.after}`) )) ), wp.element.createElement('div', { className: 'wpaw-edit-plan-actions' }, wp.element.createElement(Button, { isPrimary: true, onClick: () => applyEditPlan(plan), disabled: !plan || !isPlanActive }, `Apply (${actionCount})`), wp.element.createElement(Button, { isSecondary: true, onClick: cancelEditPlan, disabled: !isPlanActive }, 'Cancel') ) ); } if (message.type === 'error') { const handleRetry = () => { if (message.retryType === 'execute') { retryLastExecute(); return; } if (message.retryType === 'refine') { retryLastRefinement(); return; } if (message.retryType === 'chat') { retryLastChat(); return; } retryLastGeneration(); }; return wp.element.createElement('div', { key: `error-${index}`, className: 'wpaw-ai-item wpaw-message wpaw-message-error', }, wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(message.content, true)), message.canRetry && wp.element.createElement(Button, { isSecondary: true, onClick: handleRetry, }, 'Retry') ); } return wp.element.createElement('div', { key: `response-${index}`, className: 'wpaw-ai-item wpaw-response', }, wp.element.createElement('div', { className: 'wpaw-response-content' }, renderMessageContent(message.content, true)), isLoading && isLastGroup && isLastItem && wp.element.createElement('div', { className: 'wpaw-streaming-indicator', }, streamingLabel) ); }) ); }); }; // Render Config Tab // Render Config Tab - Updated for Dark Theme const renderConfigTab = () => { const isConfigDisabled = isLoading || isConfigLoading || isConfigSaving; return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-config-tab dark-theme' }, // Back Header wp.element.createElement('div', { className: 'wpaw-tab-header' }, wp.element.createElement('button', { className: 'wpaw-back-btn', onClick: () => setActiveTab('chat') }, '← Back'), wp.element.createElement('h3', null, 'CONFIGURATION') ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement('label', null, 'DEFAULT MODE'), wp.element.createElement('select', { value: postConfig.default_mode, onChange: (e) => { updatePostConfig('default_mode', e.target.value); setAgentMode(e.target.value); }, disabled: isConfigDisabled, className: 'wpaw-select' }, wp.element.createElement('option', { value: 'writing' }, 'Writing'), wp.element.createElement('option', { value: 'planning' }, 'Planning'), wp.element.createElement('option', { value: 'chat' }, 'Chat') ), wp.element.createElement('p', { className: 'description' }, 'Controls which mode opens by default for this post.' ) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement('label', null, 'ARTICLE LENGTH'), wp.element.createElement('select', { value: postConfig.article_length, onChange: (e) => updatePostConfig('article_length', e.target.value), disabled: isConfigDisabled, className: 'wpaw-select' }, wp.element.createElement('option', { value: 'short' }, 'Short (500-800 words)'), wp.element.createElement('option', { value: 'medium' }, 'Medium (800-1500 words)'), wp.element.createElement('option', { value: 'long' }, 'Long (1500-2500 words)') ), wp.element.createElement('p', { className: 'description' }, 'Controls the length and depth of the generated article.' ) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement('label', null, 'Language'), wp.element.createElement('select', { value: postConfig.language, onChange: (e) => updatePostConfig('language', e.target.value), disabled: isConfigDisabled, className: 'wpaw-select' }, wp.element.createElement('option', { value: 'auto' }, 'Auto-detect'), wp.element.createElement('option', { value: 'english' }, 'English'), wp.element.createElement('option', { value: 'indonesian' }, 'Indonesian'), wp.element.createElement('option', { value: 'spanish' }, 'Spanish'), wp.element.createElement('option', { value: 'french' }, 'French') ), wp.element.createElement('p', { className: 'description' }, 'Overrides the detected language when writing or refining.' ) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(TextControl, { label: 'Tone', value: postConfig.tone, onChange: (value) => updatePostConfig('tone', value), disabled: isConfigDisabled, placeholder: 'e.g., Friendly, persuasive, professional', }), wp.element.createElement('p', { className: 'description' }, 'Use this to consistently guide the writing tone.' ) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(TextControl, { label: 'Target Audience', value: postConfig.audience, onChange: (value) => updatePostConfig('audience', value), disabled: isConfigDisabled, placeholder: 'e.g., UMKM owners, beginners, marketers', }), wp.element.createElement('p', { className: 'description' }, 'Helps the agent align examples and vocabulary.' ) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement('label', null, 'Experience Level'), wp.element.createElement('select', { value: postConfig.experience_level, onChange: (e) => updatePostConfig('experience_level', e.target.value), disabled: isConfigDisabled, className: 'wpaw-select' }, wp.element.createElement('option', { value: 'general' }, 'General audience'), wp.element.createElement('option', { value: 'beginner' }, 'Beginner'), wp.element.createElement('option', { value: 'intermediate' }, 'Intermediate'), wp.element.createElement('option', { value: 'advanced' }, 'Advanced') ) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(CheckboxControl, { label: 'Include image suggestions', checked: Boolean(postConfig.include_images), onChange: (value) => updatePostConfig('include_images', value), disabled: isConfigDisabled, }), wp.element.createElement('p', { className: 'description' }, 'When enabled, the agent will add image placeholders.' ) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(CheckboxControl, { label: 'Enable web search for outlines', checked: Boolean(postConfig.web_search), onChange: (value) => updatePostConfig('web_search', value), disabled: isConfigDisabled, }), wp.element.createElement('p', { className: 'description' }, 'Uses web search when planning outlines.' ) ), // SEO Section wp.element.createElement('div', { className: 'wpaw-config-divider' }, wp.element.createElement('span', null, '🔍 SEO OPTIMIZATION') ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(CheckboxControl, { label: 'Enable SEO optimization', checked: Boolean(postConfig.seo_enabled), onChange: (value) => updatePostConfig('seo_enabled', value), disabled: isConfigDisabled, }), wp.element.createElement('p', { className: 'description' }, 'Include SEO guidelines in AI prompts for keyword-optimized content.' ) ), postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(TextControl, { label: 'Focus Keyword', value: postConfig.seo_focus_keyword, onChange: (value) => updatePostConfig('seo_focus_keyword', value), disabled: isConfigDisabled, placeholder: 'e.g., wordpress seo plugin', }), wp.element.createElement('p', { className: 'description' }, 'Primary keyword to optimize content for. Will be included in title, headings, and body.' ) ), postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(TextControl, { label: 'Secondary Keywords', value: postConfig.seo_secondary_keywords, onChange: (value) => updatePostConfig('seo_secondary_keywords', value), disabled: isConfigDisabled, placeholder: 'e.g., content optimization, search ranking', }), wp.element.createElement('p', { className: 'description' }, 'Comma-separated related keywords to sprinkle throughout content.' ) ), postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement(TextareaControl, { label: 'Meta Description', value: postConfig.seo_meta_description, onChange: (value) => updatePostConfig('seo_meta_description', value), disabled: isConfigDisabled, placeholder: 'Enter meta description (120-160 chars recommended)', rows: 3, }), wp.element.createElement('div', { className: 'wpaw-meta-info' }, wp.element.createElement('span', { className: (postConfig.seo_meta_description?.length || 0) >= 120 && (postConfig.seo_meta_description?.length || 0) <= 160 ? 'good' : 'warning' }, `${postConfig.seo_meta_description?.length || 0}/160 chars`), wp.element.createElement(Button, { isSecondary: true, isSmall: true, onClick: () => generateMetaDescription(), disabled: isConfigDisabled || isGeneratingMeta, }, isGeneratingMeta ? wp.element.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '5px' } }, wp.element.createElement('span', { className: 'wpaw-spinning-icon', dangerouslySetInnerHTML: { __html: '' } }), ' Generating...' ) : wp.element.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '5px' } }, wp.element.createElement('span', { className: 'wpaw-svg-wrapper', dangerouslySetInnerHTML: { __html: '' } }), ' Generate' ) ) ) ), // SEO Audit Section postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section wpaw-seo-audit' }, wp.element.createElement('div', { className: 'wpaw-seo-audit-header' }, wp.element.createElement('label', null, 'SEO Audit'), wp.element.createElement(Button, { isSecondary: true, isSmall: true, onClick: () => runSeoAudit(), disabled: isConfigDisabled || isSeoAuditing, }, isSeoAuditing ? wp.element.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '5px' } }, wp.element.createElement('span', { className: 'wpaw-spinning-icon', style: { display: 'inline-flex', lineHeight: '0' }, dangerouslySetInnerHTML: { // Icon Loader/Circle-slashed untuk kesan analyzing __html: '' } }), ' Analyzing...' ) : wp.element.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: '5px' } }, wp.element.createElement('span', { className: 'wpaw-svg-wrapper', style: { display: 'inline-flex', lineHeight: '0' }, dangerouslySetInnerHTML: { // Icon Bar-Chart untuk "Run Audit" __html: '' } }), ' Run Audit' ) ) ), seoAudit && wp.element.createElement('div', { className: 'wpaw-seo-audit-results' }, wp.element.createElement('div', { className: 'wpaw-seo-score ' + (seoAudit.score >= 70 ? 'good' : seoAudit.score >= 40 ? 'warning' : 'poor') }, wp.element.createElement('span', { className: 'score-value' }, seoAudit.score), wp.element.createElement('span', { className: 'score-label' }, '/100') ), wp.element.createElement('div', { className: 'wpaw-seo-stats' }, wp.element.createElement('div', { className: 'wpaw-seo-stat' }, wp.element.createElement('span', { className: 'stat-label' }, 'Words'), wp.element.createElement('span', { className: 'stat-value' }, seoAudit.word_count || 0) ), wp.element.createElement('div', { className: 'wpaw-seo-stat' }, wp.element.createElement('span', { className: 'stat-label' }, 'Keyword Density'), wp.element.createElement('span', { className: 'stat-value' }, `${(seoAudit.keyword_density || 0).toFixed(1)}%`) ) ), seoAudit.checks && wp.element.createElement('div', { className: 'wpaw-seo-checks' }, seoAudit.checks.map((check, idx) => { const isPassed = check.status === 'good' || check.status === 'ok'; return wp.element.createElement('div', { key: idx, className: 'wpaw-seo-check ' + (isPassed ? 'passed' : 'failed') }, wp.element.createElement('span', { className: 'check-icon' }, isPassed ? '✓' : '✗'), wp.element.createElement('span', { className: 'check-label' }, check.message) ); }) ) ), !seoAudit && wp.element.createElement('p', { className: 'description' }, 'Click "Run Audit" to analyze your content for SEO optimization.' ) ), (isConfigSaving || configError) && wp.element.createElement('div', { className: 'wpaw-config-section' }, isConfigSaving && wp.element.createElement('p', { className: 'description' }, 'Saving post configuration...'), configError && wp.element.createElement('p', { className: 'description' }, configError) ), wp.element.createElement('div', { className: 'wpaw-config-section' }, wp.element.createElement('p', { className: 'description' }, 'Configure global settings like API keys, models, and clarification quiz options in ', wp.element.createElement('a', { href: settings.settings_url || '/wp-admin/options-general.php?page=wp-agentic-writer', target: '_blank' }, 'Settings → WP Agentic Writer') ) ) ); }; // Render Chat Tab const renderChatTab = () => { // Determine agent status const getAgentStatus = () => { if (!isLoading) return 'idle'; const lastMsg = messages.filter(m => m.type === 'timeline').pop(); if (lastMsg?.message?.toLowerCase().includes('writing')) return 'writing'; if (lastMsg?.message?.toLowerCase().includes('generating')) return 'writing'; return 'thinking'; }; const agentStatus = getAgentStatus(); const statusLabels = { idle: 'Ready', thinking: 'Thinking...', writing: 'Writing...', complete: 'Done', error: 'Error' }; return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-chat-tab dark-theme' }, renderClarification(), !inClarification && wp.element.createElement('div', { className: 'wpaw-chat-container' }, // Status Bar wp.element.createElement('div', { className: 'wpaw-status-bar' }, wp.element.createElement('div', { className: 'wpaw-status-indicator' }, 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' }, // Undo Button aiUndoStack.length > 0 && wp.element.createElement('button', { className: 'wpaw-status-icon-btn wpaw-undo-btn', title: `Undo: ${aiUndoStack[aiUndoStack.length - 1]?.label || 'Last AI operation'}`, onClick: undoLastAiOperation, disabled: isLoading }, '↩️'), // Cost Label wp.element.createElement('span', { className: 'wpaw-status-cost' }, 'Session: $' + cost.session.toFixed(4) ), // Config Icon Button wp.element.createElement('button', { className: 'wpaw-status-icon-btn', dangerouslySetInnerHTML: { __html: '' }, title: 'Configuration', onClick: () => setActiveTab('config') }), // Cost Icon Button wp.element.createElement('button', { className: 'wpaw-status-icon-btn', dangerouslySetInnerHTML: { __html: '' }, title: 'Cost Tracking', onClick: () => setActiveTab('cost') }) ) ), // Editor Lock Banner isEditorLocked && wp.element.createElement('div', { className: 'wpaw-editor-lock-banner' }, 'Writing in progress — please wait until the article finishes.' ), // Activity Log wp.element.createElement('div', { className: 'wpaw-messages wpaw-activity-log' }, wp.element.createElement('div', { className: 'wpaw-messages-inner', ref: messagesContainerRef }, renderMessages(), wp.element.createElement('div', { ref: messagesEndRef }) ) ), // Command Input Area wp.element.createElement('div', { className: 'wpaw-command-area', style: { position: 'relative' } }, // Removed Toolbar from Top wp.element.createElement('div', { className: 'wpaw-command-input-wrapper' }, wp.element.createElement('span', { className: 'wpaw-command-prefix' }, '>'), wp.element.createElement(TextareaControl, { ref: inputRef, value: input, onChange: handleInputChange, onKeyDown: handleKeyDown, placeholder: agentMode === 'planning' ? 'Describe what you want to write about...' : agentMode === 'chat' ? 'Ask me anything about your content...' : 'Tell me what to write. Use @block to refine.', }) ), showMentionAutocomplete && mentionOptions.length > 0 && wp.element.createElement('div', { className: 'wpaw-mention-autocomplete', style: { position: 'absolute', bottom: '100%', left: 0, right: 0, maxHeight: '200px', overflowY: 'auto', background: '#1e1e1e', border: '1px solid #3c3c3c', zIndex: 1000 } }, mentionOptions.map((option, index) => { const isSelected = index === mentionCursorIndex; return wp.element.createElement('div', { key: option.id, className: 'wpaw-mention-option' + (isSelected ? ' selected' : ''), onClick: () => insertMention(option), style: { padding: '8px 12px', cursor: 'pointer', background: isSelected ? '#2c2c2c' : 'transparent', borderBottom: '1px solid #3c3c3c' } }, wp.element.createElement('strong', { style: { display: 'block', color: '#fff', fontSize: '13px' } }, option.label), wp.element.createElement('span', { style: { display: 'block', color: '#a7aaad', fontSize: '12px', marginTop: '2px' } }, option.sublabel) ); }) ), showSlashAutocomplete && slashOptions.length > 0 && wp.element.createElement('div', { className: 'wpaw-mention-autocomplete', style: { position: 'absolute', bottom: '100%', left: 0, right: 0, maxHeight: '200px', overflowY: 'auto', background: '#1e1e1e', border: '1px solid #3c3c3c', zIndex: 1000 } }, slashOptions.map((option, index) => { const isSelected = index === slashCursorIndex; return wp.element.createElement('div', { key: option.id, className: 'wpaw-mention-option' + (isSelected ? ' selected' : ''), onClick: () => insertSlashCommand(option), style: { padding: '8px 12px', cursor: 'pointer', background: isSelected ? '#2c2c2c' : 'transparent', borderBottom: '1px solid #3c3c3c' } }, wp.element.createElement('strong', { style: { display: 'block', color: '#fff', fontSize: '13px' } }, option.label), wp.element.createElement('span', { style: { display: 'block', color: '#a7aaad', fontSize: '12px', marginTop: '2px' } }, option.sublabel) ); }) ), wp.element.createElement('div', { className: 'wpaw-command-actions' }, wp.element.createElement('div', { className: 'wpaw-command-actions-group' }, // Mode Selector (Bottom Left) wp.element.createElement('div', { className: 'wpaw-command-mode-wrapper' }, wp.element.createElement('span', { className: 'wpaw-command-label' }, 'MODE:'), wp.element.createElement('select', { className: 'wpaw-command-mode-select', id: 'agentMode', value: agentMode, onChange: (e) => setAgentMode(e.target.value), disabled: isLoading, }, wp.element.createElement('option', { value: 'writing' }, 'Writing'), wp.element.createElement('option', { value: 'planning' }, 'Planning'), wp.element.createElement('option', { value: 'chat' }, 'Chat') ) ), // Web Search Toggle (next to mode) wp.element.createElement('label', { className: 'wpaw-web-search-toggle', title: 'Enable web search for current data (costs ~$0.02/search)', }, wp.element.createElement('input', { type: 'checkbox', checked: postConfig.web_search || false, onChange: (e) => updatePostConfig('web_search', e.target.checked), disabled: isLoading, }), wp.element.createElement('span', { className: 'wpaw-web-search-icon', dangerouslySetInnerHTML: { __html: '' } }), wp.element.createElement('span', { className: 'wpaw-web-search-label' }, 'Search') ), ), wp.element.createElement('div', { className: 'wpaw-command-actions-group' }, // Clear Context (Bottom Middle-ish) wp.element.createElement('button', { className: 'wpaw-command-text-btn', type: 'button', onClick: clearChatContext, disabled: isLoading, }, 'Clear Context'), // Execute Button (Bottom Right) wp.element.createElement('button', { className: 'wpaw-command-btn', onClick: sendMessage, disabled: isLoading || !input.trim(), }, isLoading ? 'Executing...' : 'Send') ) ) ) ) ); }; // Refresh cost data from server const [costHistory, setCostHistory] = wp.element.useState([]); const refreshCostData = async () => { if (!postId) return; try { const response = await fetch(`${wpAgenticWriter.apiUrl}/cost-tracking/${postId}`, { headers: { 'X-WP-Nonce': wpAgenticWriter.nonce }, }); const data = await response.json(); if (data && typeof data.session === 'number') { setCost({ session: data.session, today: data.today?.total?.cost || 0, monthlyUsed: data.monthly?.used || 0, }); } if (data?.monthly?.budget) { setMonthlyBudget(data.monthly.budget); } if (data?.history) { setCostHistory(data.history); } } catch (e) { console.error('Failed to refresh cost data:', e); } }; // Render Cost Tab const renderCostTab = () => { const budgetPercent = monthlyBudget > 0 ? (cost.monthlyUsed / monthlyBudget) * 100 : 0; const budgetStatus = budgetPercent > 90 ? 'danger' : budgetPercent > 70 ? 'warning' : 'ok'; const remaining = Math.max(0, monthlyBudget - cost.monthlyUsed); return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-cost-tab dark-theme' }, wp.element.createElement('div', { className: 'wpaw-tab-header' }, wp.element.createElement('button', { className: 'wpaw-back-btn', onClick: () => setActiveTab('chat') }, '← Back'), wp.element.createElement('h3', null, 'COST TRACKING'), wp.element.createElement('button', { className: 'wpaw-refresh-btn', dangerouslySetInnerHTML: { __html: '' }, onClick: refreshCostData, title: 'Refresh cost data' }) ), wp.element.createElement('div', { className: 'wpaw-cost-card' }, wp.element.createElement('div', { className: 'wpaw-cost-stat' }, wp.element.createElement('label', null, 'This Post'), wp.element.createElement('div', { className: 'wpaw-cost-value' }, '$', cost.session.toFixed(4) ) ), wp.element.createElement('div', { className: 'wpaw-cost-stat' }, wp.element.createElement('label', null, 'Month Used'), wp.element.createElement('div', { className: 'wpaw-cost-value' }, '$', cost.monthlyUsed.toFixed(4) ) ), wp.element.createElement('div', { className: 'wpaw-cost-stat wpaw-cost-remaining' }, wp.element.createElement('label', null, 'Remaining'), wp.element.createElement('div', { className: 'wpaw-cost-value ' + budgetStatus }, '$', remaining.toFixed(2) ) ) ), wp.element.createElement('div', { className: 'wpaw-budget-section' }, wp.element.createElement('div', { className: 'wpaw-budget-label' }, wp.element.createElement('span', null, 'Budget: $', monthlyBudget.toFixed(2)), wp.element.createElement('span', null, budgetPercent.toFixed(1), '%') ), wp.element.createElement('div', { className: 'wpaw-budget-bar' }, wp.element.createElement('div', { className: 'wpaw-budget-fill ' + budgetStatus, style: { width: Math.min(budgetPercent, 100) + '%' } }) ) ), budgetPercent > 80 && wp.element.createElement('div', { className: 'wpaw-budget-warning ' + budgetStatus, }, budgetPercent >= 100 ? '⚠️ Budget exceeded!' : '⚠️ Approaching budget limit'), costHistory.length > 0 && wp.element.createElement('div', { className: 'wpaw-cost-history' }, wp.element.createElement('h4', null, 'Cost History'), wp.element.createElement('div', { className: 'wpaw-cost-table-wrapper' }, wp.element.createElement('table', { className: 'wpaw-cost-table' }, wp.element.createElement('thead', null, wp.element.createElement('tr', null, wp.element.createElement('th', null, 'Time'), wp.element.createElement('th', null, 'Action'), wp.element.createElement('th', null, 'Model'), wp.element.createElement('th', null, 'Tokens'), wp.element.createElement('th', null, 'Cost') ) ), wp.element.createElement('tbody', null, costHistory.map((record, idx) => { const totalTokens = parseInt(record.input_tokens || 0) + parseInt(record.output_tokens || 0); const time = new Date(record.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const modelShort = record.model ? record.model.split('/').pop().substring(0, 20) : 'N/A'; return wp.element.createElement('tr', { key: idx }, wp.element.createElement('td', null, time), wp.element.createElement('td', null, record.action), wp.element.createElement('td', { title: record.model }, modelShort), wp.element.createElement('td', null, totalTokens.toLocaleString()), wp.element.createElement('td', null, '$' + parseFloat(record.cost).toFixed(4)) ); }) ) ) ) ), wp.element.createElement('div', { className: 'wpaw-cost-footer' }, wp.element.createElement('a', { href: settings.settings_url || '/wp-admin/options-general.php?page=wp-agentic-writer', target: '_blank', className: 'wpaw-cost-settings-link' }, wp.element.createElement('span', { dangerouslySetInnerHTML: { __html: ' Manage Budget Settings' } }), ) ) ); }; // Main render. return wp.element.createElement(PluginSidebar, { name: 'wp-agentic-writer', title: 'WP Agentic Writer' }, wp.element.createElement(Panel, null, wp.element.createElement('div', { className: 'wpaw-tab-content-wrapper' }, activeTab === 'chat' && renderChatTab(), activeTab === 'config' && renderConfigTab(), activeTab === 'cost' && renderCostTab() ) ) ); }; // HOC to get post ID. const mapSelectToProps = (select) => ({ postId: select('core/editor').getCurrentPostId(), }); // Connect sidebar to Redux store. const ConnectedSidebar = wp.data.withSelect(mapSelectToProps)(AgenticWriterSidebar); // Register plugin. registerPlugin('wp-agentic-writer', { icon: 'edit', render: ConnectedSidebar, }); })(window.wp);