/** * 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(/ (.*?)<\/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(/ (.*?)<\/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(/ (.*?)<\/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(/ (.*?)<\/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(/ (.*?)<\/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) => ` ${inlineMarkdownToHtml(paragraph.join(' '))} ${inlineMarkdownToHtml(detail)}([\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]*?)<\/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(/ ${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(`
`);
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 += `${escapeHtml(code)}${item.children.map((child) => `
`
: '';
return `