Files
wp-agentic-writer/assets/js/sidebar.js.bak
2026-01-28 00:26:00 +07:00

5153 lines
173 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>(.*?)<\/p>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/paragraph', { content: content });
} else if (data.block.blockName === 'core/heading') {
const level = data.block.attrs?.level || 2;
const content = data.block.innerHTML?.match(/<h[1-6]>(.*?)<\/h[1-6]>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content });
} else if (data.block.blockName === 'core/list') {
const listItems = (data.block.innerBlocks || []).map(item => {
const content = item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || '';
return wp.blocks.createBlock('core/list-item', { content: content });
});
newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems);
} else if (data.block.blockName === 'core/quote') {
const content = data.block.innerHTML?.match(/<p>(.*?)<\/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 agents chat memory (including stored chat history) for this post. It wont 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(/<code>([\s\S]*?)<\/code>/i);
if (match && match[1]) {
attrs.content = match[1]
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"');
}
}
// Handle table blocks - extract head and body from innerHTML
if (block.blockName === 'core/table' && block.innerHTML) {
const headMatch = block.innerHTML.match(/<thead>([\s\S]*?)<\/thead>/i);
const bodyMatch = block.innerHTML.match(/<tbody>([\s\S]*?)<\/tbody>/i);
if (headMatch || bodyMatch) {
attrs.head = [];
attrs.body = [];
// Parse thead rows
if (headMatch) {
const headRows = headMatch[1].match(/<tr>([\s\S]*?)<\/tr>/gi) || [];
headRows.forEach(row => {
const cells = [];
const cellMatches = row.match(/<t[hd]>([\s\S]*?)<\/t[hd]>/gi) || [];
cellMatches.forEach(cell => {
const content = cell.replace(/<\/?t[hd]>/gi, '');
cells.push({ content, tag: 'th' });
});
if (cells.length > 0) attrs.head.push({ cells });
});
}
// Parse tbody rows
if (bodyMatch) {
const bodyRows = bodyMatch[1].match(/<tr>([\s\S]*?)<\/tr>/gi) || [];
bodyRows.forEach(row => {
const cells = [];
const cellMatches = row.match(/<td>([\s\S]*?)<\/td>/gi) || [];
cellMatches.forEach(cell => {
const content = cell.replace(/<\/?td>/gi, '');
cells.push({ content, tag: 'td' });
});
if (cells.length > 0) attrs.body.push({ cells });
});
}
}
}
// Handle button blocks from [CTA:...] syntax
if (block.blockName === 'core/buttons' || block.blockName === 'core/button') {
if (block.blockName === 'core/button') {
return wp.blocks.createBlock('core/buttons', {}, [
wp.blocks.createBlock('core/button', attrs)
]);
}
}
if (block.innerBlocks && block.innerBlocks.length > 0) {
const innerBlocks = block.innerBlocks.map((innerBlock) => (
createBlocksFromSerialized(innerBlock)
)).filter(Boolean);
return wp.blocks.createBlock(block.blockName, attrs, innerBlocks);
}
return wp.blocks.createBlock(block.blockName, attrs);
};
const reformatBlocks = async (blocksToReformat, originalMessage) => {
if (isLoading) {
return;
}
if (!blocksToReformat || blocksToReformat.length === 0) {
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'No blocks found to reformat.'
}]);
return;
}
setIsLoading(true);
setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
role: 'system',
type: 'timeline',
status: 'refining',
message: `Reformatting ${blocksToReformat.length} block(s)...`,
timestamp: new Date()
}]);
try {
const response = await fetch(wpAgenticWriter.apiUrl + '/reformat-blocks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
blocks: blocksToReformat,
postId: postId,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to reformat blocks');
}
const data = await response.json();
const results = data.results || [];
const { replaceBlocks } = dispatch('core/block-editor');
const currentTitle = select('core/editor').getEditedPostAttribute('title') || '';
results.forEach((result) => {
const newBlocks = (result.blocks || []).map(createBlocksFromSerialized).filter(Boolean);
if (newBlocks.length > 0) {
replaceBlocks(result.clientId, newBlocks);
}
});
setMessages(prev => [...prev, {
role: 'system',
type: 'timeline',
status: 'complete',
message: `Reformatted ${results.length} block(s).`,
timestamp: new Date(),
completedAt: new Date()
}]);
if (data.recommended_title) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Suggested title: ${data.recommended_title}`
}]);
if (data.title_updated || !currentTitle) {
dispatch('core/editor').editPost({ title: data.recommended_title });
}
}
} catch (error) {
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'Error: ' + (error.message || 'Failed to reformat blocks')
}]);
} finally {
setIsLoading(false);
}
};
const revisePlanFromPrompt = async (instruction) => {
if (isLoading) {
return;
}
const existingPlan = currentPlanRef.current;
if (!existingPlan) {
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'No outline found to revise. Generate an outline first.'
}]);
return;
}
setIsLoading(true);
setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
role: 'system',
type: 'timeline',
status: 'planning',
message: 'Updating outline...',
timestamp: new Date()
}]);
try {
const response = await fetch(wpAgenticWriter.apiUrl + '/revise-plan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
instruction: instruction,
plan: existingPlan,
postId: postId,
postConfig: postConfig,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to revise outline');
}
const data = await response.json();
if (data.plan) {
updateOrCreatePlanMessage(data.plan, { append: true });
}
if (data.cost) {
setCost({ ...cost, session: cost.session + data.cost });
}
setMessages(prev => {
const newMessages = [...prev];
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
if (lastTimelineIndex !== -1) {
newMessages[lastTimelineIndex] = {
...newMessages[lastTimelineIndex],
status: 'complete',
message: 'Outline updated.',
completedAt: new Date()
};
}
return newMessages;
});
} catch (error) {
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'Error: ' + (error.message || 'Failed to revise outline')
}]);
} finally {
setIsLoading(false);
}
};
const applyEditPlan = (plan) => {
const actions = normalizePlanActions(plan);
if (actions.length === 0) {
setPendingEditPlan(null);
return;
}
// Capture snapshot before applying changes
pushUndoSnapshot('Apply Edit Plan');
const { replaceBlocks, insertBlocks, removeBlocks } = dispatch('core/block-editor');
const allBlocks = select('core/block-editor').getBlocks();
const baseIndexById = new Map(allBlocks.map((block, index) => [block.clientId, index]));
const insertOffsets = {};
const existingIds = new Set(allBlocks.map((block) => block.clientId));
actions.forEach((action) => {
if (action.action === 'keep') {
return;
}
if (action.blockId && !existingIds.has(action.blockId)) {
return;
}
if (action.action === 'delete' && action.blockId) {
removeBlocks(action.blockId);
return;
}
if (action.action === 'change_type' && action.blockId) {
const newBlock = createBlockFromPlan(action);
replaceBlocks(action.blockId, newBlock);
return;
}
if (action.action === 'replace' && action.blockId) {
const newBlock = createBlockFromPlan(action);
replaceBlocks(action.blockId, newBlock);
return;
}
if ((action.action === 'insert_after' || action.action === 'insert_before') && action.blockId) {
const baseIndex = baseIndexById.get(action.blockId);
const offsets = insertOffsets[action.blockId] || { before: 0, after: 0 };
let insertIndex;
if (typeof baseIndex === 'number') {
if (action.action === 'insert_before') {
insertIndex = baseIndex + offsets.before;
offsets.before += 1;
} else {
insertIndex = baseIndex + offsets.before + 1 + offsets.after;
offsets.after += 1;
}
}
insertOffsets[action.blockId] = offsets;
const newBlock = createBlockFromPlan(action);
insertBlocks(newBlock, insertIndex);
}
});
setPendingEditPlan(null);
setMessages(prev => [...prev, {
role: 'system',
type: 'timeline',
status: 'complete',
message: 'Changes applied.',
}]);
};
const cancelEditPlan = () => {
setPendingEditPlan(null);
setMessages(prev => [...prev, {
role: 'system',
type: 'timeline',
status: 'inactive',
message: 'Changes cancelled.',
}]);
};
const formatClarificationContext = (questionsList, answersMap) => {
if (!questionsList || questionsList.length === 0) {
return '';
}
const lines = [];
questionsList.forEach((question) => {
const answer = answersMap[question.id];
if (!answer) {
return;
}
lines.push(`- ${question.question || question.prompt || 'Question'}: ${answer}`);
});
if (lines.length === 0) {
return '';
}
return `\n\nClarification Answers:\n${lines.join('\n')}`;
};
// Auto-select first option when question changes
React.useEffect(() => {
if (inClarification && questions.length > 0 && questions[currentQuestionIndex]) {
const currentQuestion = questions[currentQuestionIndex];
if (currentQuestion.type === 'single_choice' && currentQuestion.options && currentQuestion.options.length > 0 && !answers[currentQuestion.id]) {
const newAnswers = { ...answers };
newAnswers[currentQuestion.id] = currentQuestion.options[0].value;
setAnswers(newAnswers);
}
}
}, [currentQuestionIndex, questions, inClarification]);
/**
* Remove duplicate adjacent heading blocks
*/
const removeDuplicateHeadings = (blocks) => {
if (!blocks || blocks.length === 0) {
return blocks;
}
const cleanedBlocks = [];
let lastHeadingContent = null;
for (const block of blocks) {
if (block.name === 'core/heading') {
const currentHeading = (block.attributes?.content || '').trim().toLowerCase();
if (currentHeading === lastHeadingContent) {
console.log('WP Agentic Writer: Removed duplicate heading:', block.attributes.content);
continue;
}
lastHeadingContent = currentHeading;
} else {
lastHeadingContent = null;
}
cleanedBlocks.push(block);
}
return cleanedBlocks;
};
// Send message and generate article.
// Resolve block mentions to client IDs
const getRefineableBlocks = () => {
const allBlocks = select('core/block-editor').getBlocks();
return allBlocks.filter((block) => {
if (!block.name || !block.name.startsWith('core/')) {
return false;
}
// Filter out empty blocks (e.g., default empty paragraph on new posts)
const content = block.attributes?.content || '';
const hasInnerBlocks = block.innerBlocks && block.innerBlocks.length > 0;
// Consider block as refineable only if it has content or inner blocks
return content.trim().length > 0 || hasInnerBlocks;
});
};
const getListItemBlocks = () => {
const allBlocks = select('core/block-editor').getBlocks();
const listItems = [];
let listBlockIndex = 0;
allBlocks.forEach((block) => {
if (block.name !== 'core/list') {
return;
}
listBlockIndex += 1;
const innerItems = Array.isArray(block.innerBlocks) ? block.innerBlocks : [];
innerItems.forEach((itemBlock, itemIndex) => {
if (itemBlock.name !== 'core/list-item') {
return;
}
listItems.push({
block: itemBlock,
parentId: block.clientId,
listIndex: listBlockIndex,
itemIndex: itemIndex
});
});
});
return listItems;
};
const resolveExplicitListItem = (listIndex, itemIndex) => {
const listItems = getListItemBlocks();
return listItems.find(
(item) => item.listIndex === listIndex && item.itemIndex === itemIndex
);
};
const getParentListId = (blockId) => {
const getParents = select('core/block-editor').getBlockParents;
if (!getParents) {
return null;
}
const parentIds = getParents(blockId);
for (const parentId of parentIds) {
const parentBlock = select('core/block-editor').getBlock(parentId);
if (parentBlock?.name === 'core/list') {
return parentId;
}
}
return null;
};
const getBlockContentForContext = (blockId) => {
const block = blockId ? select('core/block-editor').getBlock(blockId) : null;
if (!block) {
return '';
}
const content = extractBlockPreview(block);
return content ? content.trim() : '';
};
const getHeadingContextForBlock = (blockId) => {
const allBlocks = select('core/block-editor').getBlocks();
const startIndex = allBlocks.findIndex((block) => block.clientId === blockId);
if (startIndex === -1) {
return '';
}
for (let i = startIndex - 1; i >= 0; i -= 1) {
if (allBlocks[i].name === 'core/heading') {
return extractBlockPreview(allBlocks[i]) || '';
}
}
return '';
};
const getNearbyParagraphContext = (blockId, limit = 2) => {
const allBlocks = select('core/block-editor').getBlocks();
const startIndex = allBlocks.findIndex((block) => block.clientId === blockId);
if (startIndex === -1) {
return [];
}
const snippets = [];
for (let i = startIndex - 1; i >= 0 && snippets.length < limit; i -= 1) {
if (allBlocks[i].name === 'core/paragraph') {
const preview = extractBlockPreview(allBlocks[i]);
if (preview) {
snippets.push(preview.trim());
}
}
if (allBlocks[i].name === 'core/heading') {
break;
}
}
return snippets.reverse();
};
const getContextFromMentions = (mentionTokens, excludeId) => {
const mentionIds = resolveBlockMentions(mentionTokens).filter((id) => id && id !== excludeId);
const uniqueIds = [...new Set(mentionIds)];
return uniqueIds
.map((id) => getBlockContentForContext(id))
.filter((content) => content);
};
const resolveBlockMentions = (mentions) => {
const allBlocks = select('core/block-editor').getBlocks();
const selectedBlockId = select('core/block-editor').getSelectedBlockClientId();
const resolved = [];
const listItems = getListItemBlocks();
mentions.forEach(mention => {
const type = normalizeMentionToken(mention.replace('@', ''));
const match = type.match(/^([a-z0-9-]+)-(\d+)$/i);
const listItemMatch = type.match(/^(?:listitem|list-item|li)-(\d+)$/i);
const explicitListItemMatch = type.match(/^list-(\d+)\.list-item-(\d+)$/i);
switch (type) {
case 'this':
if (selectedBlockId) {
resolved.push(selectedBlockId);
}
break;
case 'previous':
if (selectedBlockId) {
const selectedIndex = allBlocks.findIndex(b => b.clientId === selectedBlockId);
if (selectedIndex > 0) {
resolved.push(allBlocks[selectedIndex - 1].clientId);
}
}
break;
case 'next':
if (selectedBlockId) {
const selectedIndex = allBlocks.findIndex(b => b.clientId === selectedBlockId);
if (selectedIndex < allBlocks.length - 1) {
resolved.push(allBlocks[selectedIndex + 1].clientId);
}
}
break;
case 'all':
getRefineableBlocks().forEach((block) => {
resolved.push(block.clientId);
});
break;
default:
if (explicitListItemMatch) {
const listIndex = parseInt(explicitListItemMatch[1], 10);
const itemIndex = parseInt(explicitListItemMatch[2], 10);
const item = resolveExplicitListItem(listIndex, itemIndex);
if (item) {
resolved.push(item.block.clientId);
}
break;
}
if (listItemMatch) {
const rawIndex = parseInt(listItemMatch[1], 10);
const targetIndex = rawIndex <= 0 ? 1 : rawIndex;
const listItem = listItems[targetIndex - 1];
if (listItem) {
resolved.push(listItem.block.clientId);
}
break;
}
// Handle "paragraph-1", "heading-2", "list-1" format
if (match) {
const blockType = 'core/' + match[1];
const blockIndex = parseInt(match[2]) - 1; // 1-based to 0-based
let currentIndex = 0;
allBlocks.forEach((block) => {
if (block.name === blockType) {
if (currentIndex === blockIndex) {
resolved.push(block.clientId);
}
currentIndex++;
}
});
}
break;
}
});
return [...new Set(resolved)]; // Remove duplicates
};
// Handle chat-based refinement
const handleChatRefinement = async (message, blocksOverride = null, options = {}) => {
const { skipUserMessage = false, useDiffPlan = true } = options;
lastRefineRequestRef.current = { message, blocksOverride, options };
// Capture snapshot before refinement
pushUndoSnapshot('Block Refinement');
// Parse mentions from message
const mentionRegex = /@([a-z0-9-]+(?:-\d+)?|this|previous|next|all)/gi;
const mentionMatches = [...message.matchAll(mentionRegex)];
const mentions = mentionMatches.map(m => '@' + m[1]);
// Resolve to block client IDs
const blocksToRefine = blocksOverride || resolveBlockMentions(mentions);
if (blocksToRefine.length === 0) {
// No valid mentions found - alert user
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'No valid blocks found to refine. Try @this, @previous, @next, @all, @paragraph-1, @listitem-3, or @list-3.list-item-0.'
}]);
setIsLoading(false);
return;
}
const serializeBlockForApi = (block) => {
if (!block) {
return null;
}
return {
clientId: block.clientId,
name: block.name,
attributes: block.attributes || {},
innerBlocks: Array.isArray(block.innerBlocks)
? block.innerBlocks.map(serializeBlockForApi).filter(Boolean)
: [],
};
};
// Get actual block data snapshot from editor
const allBlocksSnapshot = select('core/block-editor').getBlocks();
const normalizedAllBlocks = allBlocksSnapshot
.map(serializeBlockForApi)
.filter(Boolean);
const blocksToRefineData = blocksToRefine
.map((clientId) => normalizedAllBlocks.find((block) => block.clientId === clientId))
.filter(Boolean);
// Add user message to chat
if (!skipUserMessage) {
setMessages([...messages, { role: 'user', content: message }]);
}
// Add timeline entry
setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
role: 'system',
type: 'timeline',
status: 'refining',
message: `Refining ${blocksToRefine.length} block(s)...`,
timestamp: new Date()
}]);
setIsLoading(true);
try {
// Get selected block
const selectedBlockId = select('core/block-editor').getSelectedBlockClientId();
// Call refinement endpoint with actual block data
const response = await fetch(wpAgenticWriter.apiUrl + '/refine-from-chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
topic: message,
context: message,
selectedBlockClientId: selectedBlockId,
blocksToRefine: blocksToRefineData, // Send actual block objects
allBlocks: normalizedAllBlocks,
postId: postId,
stream: true,
diffPlan: useDiffPlan,
postConfig: postConfig,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Refinement failed');
}
// Handle streaming response
streamTargetRef.current = null;
const reader = response.body.getReader();
const decoder = new TextDecoder();
let streamBuffer = '';
let refinedCount = 0;
const updatedSectionIds = new Set();
const { replaceBlocks } = dispatch('core/block-editor');
let refinementFailed = false;
let refinementErrorMessage = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
streamBuffer += decoder.decode(value, { stream: true });
const lines = streamBuffer.split('\n');
streamBuffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'error') {
refinementFailed = true;
refinementErrorMessage = data.message || 'Refinement failed.';
break;
} else if (data.type === 'edit_plan') {
setPendingEditPlan(data.plan);
setMessages(prev => [...prev, {
role: 'system',
type: 'edit_plan',
plan: data.plan,
}]);
} else if (data.type === 'block') {
// Replace block in editor
const blockData = data.block;
if (blockData.blockName && blockData.attrs) {
let newBlock;
// Create block using WordPress createBlock API
if (blockData.innerBlocks && blockData.innerBlocks.length > 0) {
// For lists with inner blocks
const innerBlocks = blockData.innerBlocks.map(innerB => {
return wp.blocks.createBlock(
innerB.blockName,
innerB.attrs
);
});
newBlock = wp.blocks.createBlock(
blockData.blockName,
blockData.attrs,
innerBlocks
);
} else {
// For simple blocks (paragraph, heading)
newBlock = wp.blocks.createBlock(
blockData.blockName,
blockData.attrs
);
}
// Replace the target block
if (newBlock && newBlock.name) {
const sectionId = blockSectionRef.current[blockData.clientId];
replaceBlocks(blockData.clientId, newBlock);
if (sectionId) {
removeSectionBlock(sectionId, blockData.clientId);
upsertSectionBlock(sectionId, newBlock.clientId);
updatedSectionIds.add(sectionId);
}
}
}
refinedCount++;
} else if (data.type === 'complete') {
// Update timeline
setMessages(prev => {
const newMessages = [...prev];
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
if (lastTimelineIndex !== -1) {
newMessages[lastTimelineIndex] = {
...newMessages[lastTimelineIndex],
status: 'complete',
message: `Refined ${refinedCount} block(s) successfully`,
timestamp: new Date()
};
}
return newMessages;
});
// Show completion message
setMessages(prev => [...prev, {
role: 'assistant',
content: `✅ Done! I've refined ${refinedCount} block(s) as requested.`
}]);
// Update cost
if (data.totalCost) {
setCost({ ...cost, session: cost.session + data.totalCost });
}
updatedSectionIds.forEach((sectionId) => {
saveSectionBlocks(sectionId);
});
}
} catch (e) {
console.error('Failed to parse streaming data:', line, e);
}
}
if (refinementFailed) {
break;
}
}
if (refinementFailed) {
break;
}
}
if (refinementFailed) {
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: `Refinement stopped: ${refinementErrorMessage}`,
canRetry: true,
retryType: 'refine'
}]);
setMessages(prev => {
const newMessages = [...prev];
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
if (lastTimelineIndex !== -1) {
newMessages[lastTimelineIndex] = {
...newMessages[lastTimelineIndex],
status: 'error',
message: 'Refinement stopped (edit plan failed)',
};
}
return newMessages;
});
}
} catch (error) {
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'Error: ' + error.message,
canRetry: true,
retryType: 'refine'
}]);
// Update timeline to show error
setMessages(prev => {
const newMessages = [...prev];
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
if (lastTimelineIndex !== -1) {
newMessages[lastTimelineIndex] = {
...newMessages[lastTimelineIndex],
status: 'error',
message: 'Refinement failed',
};
}
return newMessages;
});
} finally {
setIsLoading(false);
}
};
// Get mention options for autocomplete
const getMentionOptions = (query) => {
const allBlocks = select('core/block-editor').getBlocks();
const selectedBlockId = select('core/block-editor').getSelectedBlockClientId();
const options = [];
// Add special mentions
if (!query || 'this'.includes(query.toLowerCase())) {
options.push({
id: 'this',
label: '@this',
sublabel: 'Currently selected block',
type: 'special'
});
}
if (!query || 'previous'.includes(query.toLowerCase())) {
options.push({
id: 'previous',
label: '@previous',
sublabel: 'Block before current selection',
type: 'special'
});
}
if (!query || 'next'.includes(query.toLowerCase())) {
options.push({
id: 'next',
label: '@next',
sublabel: 'Block after current selection',
type: 'special'
});
}
if (!query || 'all'.includes(query.toLowerCase())) {
options.push({
id: 'all',
label: '@all',
sublabel: 'All content blocks',
type: 'special'
});
}
// Add numbered blocks for core blocks
const blockCounters = {};
const queryLower = query.toLowerCase();
let listItemIndex = 0;
let listBlockIndex = 0;
allBlocks.forEach((block) => {
if (!block.name || !block.name.startsWith('core/')) {
return;
}
const typeName = block.name.replace('core/', '');
blockCounters[typeName] = (blockCounters[typeName] || 0) + 1;
const blockLabel = `@${typeName}-${blockCounters[typeName]}`;
const content = extractBlockPreview(block);
const contentLower = content.toLowerCase();
if (!query || blockLabel.includes(queryLower) || contentLower.startsWith(queryLower)) {
const truncatedContent = content.length > 40 ? content.substring(0, 40) + '...' : content;
options.push({
id: blockLabel,
label: String(blockLabel),
sublabel: truncatedContent || String(typeName),
type: 'block',
clientId: block.clientId
});
}
if (block.name === 'core/list') {
listBlockIndex += 1;
const innerItems = Array.isArray(block.innerBlocks) ? block.innerBlocks : [];
innerItems.forEach((itemBlock, itemIndex) => {
if (itemBlock.name !== 'core/list-item') {
return;
}
listItemIndex += 1;
const itemLabel = `@listitem-${listItemIndex}`;
const explicitLabel = `@list-${listBlockIndex}.list-item-${itemIndex}`;
const itemContent = extractBlockPreview(itemBlock);
const itemLower = itemContent.toLowerCase();
if (!query || itemLabel.includes(queryLower) || explicitLabel.includes(queryLower) || itemLower.startsWith(queryLower)) {
const truncatedItem = itemContent.length > 40
? itemContent.substring(0, 40) + '...'
: itemContent;
options.push({
id: itemLabel,
label: String(explicitLabel),
sublabel: truncatedItem ? `List ${listBlockIndex}: ${truncatedItem}` : `List ${listBlockIndex} item`,
type: 'list-item',
clientId: itemBlock.clientId,
parentClientId: block.clientId
});
}
});
}
});
return options;
};
React.useEffect(() => {
const handleInsertMention = (event) => {
const token = event?.detail?.token;
if (!token) {
return;
}
setActiveTab('chat');
setInput((prev) => {
const prefix = prev && !/\s$/.test(prev) ? prev + ' ' : prev;
return `${prefix}${token}`;
});
setTimeout(() => {
const inputNode = inputRef.current?.textarea || inputRef.current;
if (inputNode) {
inputNode.focus();
inputNode.selectionStart = inputNode.selectionEnd = inputNode.value.length;
}
const mentionOptionsList = getMentionOptions('');
setMentionOptions(mentionOptionsList);
setShowMentionAutocomplete(mentionOptionsList.length > 0);
}, 0);
};
window.addEventListener('wpaw:insert-mention', handleInsertMention);
return () => window.removeEventListener('wpaw:insert-mention', handleInsertMention);
}, [getMentionOptions]);
// Handle input change for mention detection
const handleInputChange = (value) => {
setInput(value);
// Check if user is typing a mention
const inputNode = inputRef.current?.textarea || inputRef.current;
const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length;
const textBeforeCursor = value.substring(0, cursorPosition);
const mentionMatch = textBeforeCursor.match(/@(\w*)$/);
const slashMatch = textBeforeCursor.match(/\/([\w\s]*)$/);
if (mentionMatch) {
const query = mentionMatch[1];
setMentionQuery(query);
const options = getMentionOptions(query);
setMentionOptions(options);
setShowMentionAutocomplete(options.length > 0);
setMentionCursorIndex(0);
setShowSlashAutocomplete(false);
setSlashOptions([]);
} else if (slashMatch) {
const query = slashMatch[1];
setSlashQuery(query);
const options = getSlashOptions(query);
setSlashOptions(options);
setShowSlashAutocomplete(options.length > 0);
setSlashCursorIndex(0);
setShowMentionAutocomplete(false);
setMentionOptions([]);
} else {
setShowMentionAutocomplete(false);
setMentionOptions([]);
setShowSlashAutocomplete(false);
setSlashOptions([]);
}
};
// Handle keyboard navigation in autocomplete
const handleKeyDown = (e) => {
if (!showMentionAutocomplete && !showSlashAutocomplete) {
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
sendMessage();
}
return;
}
if (showMentionAutocomplete && e.keyCode === 40) { // Down arrow
e.preventDefault();
setMentionCursorIndex(prev => (prev + 1) % mentionOptions.length);
} else if (showMentionAutocomplete && e.keyCode === 38) { // Up arrow
e.preventDefault();
setMentionCursorIndex(prev => (prev - 1 + mentionOptions.length) % mentionOptions.length);
} else if (showMentionAutocomplete && e.keyCode === 13) { // Enter
e.preventDefault();
if (mentionOptions[mentionCursorIndex]) {
insertMention(mentionOptions[mentionCursorIndex]);
}
} else if (showSlashAutocomplete && e.keyCode === 40) { // Down arrow
e.preventDefault();
setSlashCursorIndex(prev => (prev + 1) % slashOptions.length);
} else if (showSlashAutocomplete && e.keyCode === 38) { // Up arrow
e.preventDefault();
setSlashCursorIndex(prev => (prev - 1 + slashOptions.length) % slashOptions.length);
} else if (showSlashAutocomplete && e.keyCode === 13) { // Enter
e.preventDefault();
if (slashOptions[slashCursorIndex]) {
insertSlashCommand(slashOptions[slashCursorIndex]);
}
} else if (e.keyCode === 27) { // Escape
e.preventDefault();
setShowMentionAutocomplete(false);
setShowSlashAutocomplete(false);
}
};
// Insert selected mention
const insertMention = (option) => {
const value = input;
const inputNode = inputRef.current?.textarea || inputRef.current;
const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length;
const textBeforeCursor = value.substring(0, cursorPosition);
const mentionStart = textBeforeCursor.lastIndexOf('@');
const beforeMention = value.substring(0, mentionStart);
const afterMention = value.substring(cursorPosition);
const newValue = beforeMention + option.label + ' ' + afterMention;
setInput(newValue);
setShowMentionAutocomplete(false);
setMentionOptions([]);
// Focus back on input
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
};
const insertSlashCommand = (option) => {
const value = input;
const inputNode = inputRef.current?.textarea || inputRef.current;
const cursorPosition = typeof inputNode?.selectionStart === 'number' ? inputNode.selectionStart : value.length;
const textBeforeCursor = value.substring(0, cursorPosition);
const slashStart = textBeforeCursor.lastIndexOf('/');
const beforeSlash = value.substring(0, slashStart);
const afterSlash = value.substring(cursorPosition);
const newValue = beforeSlash + option.insertText + afterSlash;
setInput(newValue);
setShowSlashAutocomplete(false);
setSlashOptions([]);
if (option.insertText.endsWith('@')) {
const mentionOptionsList = getMentionOptions('');
setMentionQuery('');
setMentionOptions(mentionOptionsList);
setShowMentionAutocomplete(mentionOptionsList.length > 0);
setMentionCursorIndex(0);
}
setTimeout(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, 0);
};
const sendMessage = async () => {
if (!input.trim() || isLoading) {
return;
}
const userMessage = input.trim();
const parsedCommand = parseInsertCommand(userMessage);
const commandMessage = parsedCommand ? parsedCommand.message : userMessage;
const mentionTokens = extractMentionsFromText(commandMessage);
const hasMentions = mentionTokens.length > 0;
const refineableBlocks = getRefineableBlocks();
const shouldShowPlan = agentMode === 'planning';
const generationLabel = agentMode === 'planning' ? 'Creating outline...' : 'Generating article...';
const reformatCommand = /^\s*(?:\/)?reformat\b/i;
if (parsedCommand) {
setIsLoading(true);
setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
role: 'system',
type: 'timeline',
status: 'refining',
message: 'Preparing insertion...',
timestamp: new Date()
}]);
await insertRefinementBlock(parsedCommand.mode, commandMessage, mentionTokens, userMessage);
setIsLoading(false);
return;
}
if (reformatCommand.test(userMessage)) {
setInput('');
setMessages([...messages, { role: 'user', content: userMessage }]);
const targetIds = hasMentions ? resolveBlockMentions(mentionTokens) : getRefineableBlocks().map((block) => block.clientId);
const allBlocks = select('core/block-editor').getBlocks();
const blocksToReformat = allBlocks.filter((block) => targetIds.includes(block.clientId));
await reformatBlocks(blocksToReformat, userMessage);
return;
}
if (agentMode === 'planning' && !hasMentions && currentPlanRef.current) {
setInput('');
setMessages([...messages, { role: 'user', content: userMessage }]);
await revisePlanFromPrompt(userMessage);
return;
}
if (agentMode === 'chat' && !hasMentions) {
setInput('');
setMessages([...messages, { role: 'user', content: userMessage }]);
setIsLoading(true);
// Store for retry
lastChatRequestRef.current = { message: userMessage };
try {
const chatHistory = messages
.filter((m) => m.role === 'user' || m.role === 'assistant')
.map((m) => ({ role: m.role, content: m.content }));
const response = await fetch(wpAgenticWriter.apiUrl + '/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
messages: [...chatHistory, { role: 'user', content: userMessage }],
postId: postId,
type: 'chat',
stream: true,
postConfig: postConfig,
}),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to chat');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let streamBuffer = '';
streamTargetRef.current = null;
while (true) {
const { done, value } = await reader.read();
if (done) break;
streamBuffer += decoder.decode(value, { stream: true });
const lines = streamBuffer.split('\n');
streamBuffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) {
continue;
}
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'error') {
throw new Error(data.message || 'Failed to chat');
}
if (data.type === 'conversational' || data.type === 'conversational_stream') {
const cleanContent = (data.content || '').trim();
if (!cleanContent) {
continue;
}
const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent);
if (!streamTarget) {
continue;
}
streamTargetRef.current = streamTarget;
if (data.type === 'conversational') {
setMessages(prev => {
const newMessages = [...prev];
const lastIdx = newMessages.length - 1;
const lastMessage = newMessages[lastIdx];
if (lastMessage && lastMessage.role === 'assistant' && lastMessage.content === cleanContent) {
return newMessages;
}
newMessages.push({ role: 'assistant', content: cleanContent });
return newMessages;
});
} else {
setMessages(prev => {
const newMessages = [...prev];
const lastIdx = newMessages.length - 1;
if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') {
newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent };
} else {
newMessages.push({ role: 'assistant', content: cleanContent });
}
return newMessages;
});
}
} else if (data.type === 'complete' && data.totalCost) {
setCost({ ...cost, session: cost.session + data.totalCost });
}
} catch (parseError) {
console.error('Failed to parse streaming data:', line, parseError);
}
}
}
} catch (error) {
const errorMsg = error.message || 'Failed to chat';
const isRateLimit = errorMsg.includes('429') || errorMsg.toLowerCase().includes('rate limit');
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: isRateLimit
? 'Rate limit exceeded. Please wait a moment and try again.'
: 'Error: ' + errorMsg,
canRetry: true,
retryType: 'chat'
}]);
}
setIsLoading(false);
return;
}
if (!hasMentions && refineableBlocks.length > 0) {
// Content exists - run clarity check before full-article refinement
const targetedBlocks = getTargetedRefinementBlocks(userMessage);
const matchedSection = !targetedBlocks ? findBestPlanSectionMatch(userMessage) : null;
const matchedSectionBlocks = matchedSection
? sectionBlocksRef.current[matchedSection.id] || []
: [];
setInput('');
setMessages([...messages, { role: 'user', content: userMessage }]);
if (matchedSectionBlocks.length > 0) {
setMessages(prev => [...prev, {
role: 'assistant',
content: `Targeting section: ${matchedSection.heading || matchedSection.title || 'Selected section'} (${matchedSectionBlocks.length} block(s)).`
}]);
}
setIsLoading(true);
setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
role: 'system',
type: 'timeline',
status: 'checking',
message: matchedSection
? `Analyzing request (targeting: ${matchedSection.heading || matchedSection.title || 'section'})...`
: 'Analyzing request...',
timestamp: new Date()
}]);
const fallbackBlocks = refineableBlocks.map((block) => block.clientId);
await handleChatRefinement(
userMessage,
(targetedBlocks && targetedBlocks.length > 0)
? targetedBlocks
: (matchedSectionBlocks.length > 0 ? matchedSectionBlocks : fallbackBlocks),
{ skipUserMessage: true }
);
return;
}
if (!hasMentions) {
// No mentions - check clarity first before article generation
setInput('');
setMessages([...messages, { role: 'user', content: userMessage }]);
setIsLoading(true);
// Check clarity first
setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
role: 'system',
type: 'timeline',
status: 'checking',
message: 'Analyzing request...',
timestamp: new Date()
}]);
// First try clarity check
try {
const clarityResponse = await fetch(wpAgenticWriter.apiUrl + '/check-clarity', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
topic: userMessage,
answers: [],
postId: postId,
mode: 'generation',
postConfig: postConfig,
chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10),
}),
});
if (clarityResponse.ok) {
const clarityData = await clarityResponse.json();
const clarityResult = clarityData.result;
// Store detected language for article generation
if (clarityResult.detected_language) {
setDetectedLanguage(clarityResult.detected_language);
}
if (!clarityResult.is_clear && clarityResult.questions && clarityResult.questions.length > 0) {
// Need clarification - show quiz
setQuestions(clarityResult.questions);
setInClarification(true);
setCurrentQuestionIndex(0);
setAnswers([]);
setIsLoading(false);
// Update timeline
setMessages(prev => {
const newMessages = [...prev];
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
if (lastTimelineIndex !== -1) {
newMessages[lastTimelineIndex] = {
...newMessages[lastTimelineIndex],
status: 'waiting',
message: 'Waiting for clarification...'
};
}
return newMessages;
});
return;
}
}
// If clarity check fails, proceed with generation anyway
} catch (clarityError) {
console.warn('Clarity check failed, proceeding with generation:', clarityError);
// Continue to article generation
}
// Clear enough - proceed with article generation
// Update timeline
setMessages(prev => {
const newMessages = [...prev];
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
if (lastTimelineIndex !== -1) {
newMessages[lastTimelineIndex] = {
...newMessages[lastTimelineIndex],
status: 'starting',
message: generationLabel
};
}
return newMessages;
});
// Now call generate-plan
try {
const response = await fetch(wpAgenticWriter.apiUrl + '/generate-plan', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-WP-Nonce': wpAgenticWriter.nonce,
},
body: JSON.stringify({
topic: userMessage,
context: '',
postId: postId,
answers: [],
autoExecute: agentMode !== 'planning',
stream: true,
articleLength: postConfig.article_length,
detectedLanguage: detectedLanguage,
postConfig: postConfig,
chatHistory: messages.filter(m => m.role === 'user' || m.role === 'assistant').slice(-10),
}),
});
if (!response.ok) {
const error = await response.json();
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'Error: ' + (error.message || 'Failed to generate article'),
canRetry: true
}]);
setIsLoading(false);
return;
}
// Handle streaming response
streamTargetRef.current = null;
const reader = response.body.getReader();
const decoder = new TextDecoder();
// Add timeout to detect hanging responses
const timeout = setTimeout(() => {
if (isLoading) {
console.error('Generation timeout - no response received');
setMessages(prev => [...prev, {
role: 'system',
type: 'error',
content: 'Request timeout. The AI is taking too long to respond. Please try again.',
canRetry: true
}]);
setIsLoading(false);
reader.cancel();
}
}, 120000); // 2 minute timeout
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.type === 'plan') {
setCost({ ...cost, session: cost.session + data.cost });
if (shouldShowPlan && data.plan) {
updateOrCreatePlanMessage(data.plan);
}
} else if (data.type === 'title_update') {
dispatch('core/editor').editPost({ title: data.title });
} else if (data.type === 'status') {
if (data.status === 'complete') {
continue;
}
// Update timeline
setMessages(prev => {
const newMessages = [...prev];
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
if (lastTimelineIndex !== -1) {
newMessages[lastTimelineIndex] = {
...newMessages[lastTimelineIndex],
status: data.status,
message: data.message,
icon: data.icon
};
}
return newMessages;
});
} else if (data.type === 'conversational' || data.type === 'conversational_stream') {
// Remove article marker and clean content
const cleanContent = (data.content || '')
.replace(/~~~ARTICLE~+/g, '')
.replace(/~~~ARTICLE~~~[\r\n]*/g, '')
.trim();
// Skip if content is empty after cleaning
if (!cleanContent || shouldSkipPlanningCompletion(cleanContent)) {
continue;
}
const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent);
if (!streamTarget) {
continue;
}
streamTargetRef.current = streamTarget;
if (streamTarget === 'timeline') {
updateOrCreateTimelineEntry(cleanContent);
} else {
// This is actual conversational content - add as chat bubble
if (data.type === 'conversational') {
setMessages(prev => [...prev, { role: 'assistant', content: cleanContent }]);
} else {
setMessages(prev => {
const newMessages = [...prev];
const lastIdx = newMessages.length - 1;
if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') {
newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent };
} else {
newMessages.push({ role: 'assistant', content: cleanContent });
}
return newMessages;
});
}
}
} else if (data.type === 'block') {
const { insertBlocks } = dispatch('core/block-editor');
let newBlock;
if (data.block.blockName === 'core/paragraph') {
const content = data.block.innerHTML?.match(/<p>(.*?)<\/p>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/paragraph', { content: content });
} else if (data.block.blockName === 'core/heading') {
const level = data.block.attrs?.level || 2;
const content = data.block.innerHTML?.match(/<h[1-6]>(.*?)<\/h[1-6]>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content });
} else if (data.block.blockName === 'core/list') {
const listItems = (data.block.innerBlocks || []).map(item => {
const content = item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || '';
return wp.blocks.createBlock('core/list-item', { content: content });
});
newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems);
} else if (data.block.blockName === 'core/quote') {
const content = data.block.innerHTML?.match(/<p>(.*?)<\/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>(.*?)<\/p>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/paragraph', { content: content });
} else if (data.block.blockName === 'core/heading') {
const level = data.block.attrs?.level || 2;
const content = data.block.innerHTML?.match(/<h[1-6]>(.*?)<\/h[1-6]>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content });
} else if (data.block.blockName === 'core/list') {
const listItems = (data.block.innerBlocks || []).map(item => {
const content = item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || '';
return wp.blocks.createBlock('core/list-item', { content: content });
});
newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems);
} else if (data.block.blockName === 'core/quote') {
const content = data.block.innerHTML?.match(/<p>(.*?)<\/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>(.*?)<\/p>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/paragraph', { content: content });
} else if (data.block.blockName === 'core/heading') {
const level = data.block.attrs?.level || 2;
const content = data.block.innerHTML?.match(/<h[1-6]>(.*?)<\/h[1-6]>/)?.[1] || '';
newBlock = wp.blocks.createBlock('core/heading', { level: level, content: content });
} else if (data.block.blockName === 'core/list') {
const listItems = (data.block.innerBlocks || []).map(item => {
const content = item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || '';
return wp.blocks.createBlock('core/list-item', { content: content });
});
newBlock = wp.blocks.createBlock('core/list', { ordered: data.block.attrs?.ordered || false }, listItems);
} else if (data.block.blockName === 'core/quote') {
const content = data.block.innerHTML?.match(/<p>(.*?)<\/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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
};
const inlineMarkdownToHtml = (text) => {
let html = escapeHtml(text);
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => (
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`
));
html = html.replace(/`([^`]+)`/g, (match, code) => `<code>${escapeHtml(code)}</code>`);
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>');
html = html.replace(/\*([^*]+)\*/g, '<em>$1</em>');
html = html.replace(/_([^_]+)_/g, '<em>$1</em>');
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(`<pre><code${safeLang}>${escapeHtml(code)}</code></pre>`);
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 += `<p>${inlineMarkdownToHtml(paragraph.join(' '))}</p>`;
paragraph = [];
}
};
const flushList = () => {
if (list) {
const items = list.items.map((item) => {
const details = item.details && item.details.length > 0
? item.details.map((detail) => `<p>${inlineMarkdownToHtml(detail)}</p>`).join('')
: '';
const children = item.children && item.children.length > 0
? `<ul>${item.children.map((child) => `<li>${inlineMarkdownToHtml(child)}</li>`).join('')}</ul>`
: '';
return `<li>${inlineMarkdownToHtml(item.content)}${details}${children}</li>`;
}).join('');
html += `<${list.type}>${items}</${list.type}>`;
list = null;
}
};
const addListItem = (targetList, value) => {
targetList.items.push({ content: value, children: [], details: [] });
lastLineWasListItem = true;
};
const addDetailToLastItem = (targetList, value, newParagraph) => {
const lastItem = targetList.items[targetList.items.length - 1];
if (!lastItem) {
return;
}
if (newParagraph || lastItem.details.length === 0) {
lastItem.details.push(value);
} else {
lastItem.details[lastItem.details.length - 1] += ` ${value}`;
}
lastLineWasListItem = false;
};
const getListType = (value) => {
if (/^\d+\.\s+/.test(value)) {
return 'ol';
}
if (/^[-*+]\s+/.test(value)) {
return 'ul';
}
return null;
};
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed === '') {
let nextIndex = i + 1;
while (nextIndex < lines.length && lines[nextIndex].trim() === '') {
nextIndex += 1;
}
const nextLine = nextIndex < lines.length ? lines[nextIndex].trim() : '';
const nextType = getListType(nextLine);
if (list && nextType && nextType === list.type) {
continue;
}
if (
list
&& list.type === 'ol'
&& nextLine
&& !nextType
&& !nextLine.startsWith('@@CODEBLOCK')
&& ! /^(#{1,6})\s+/.test(nextLine)
) {
detailBreak = true;
lastLineWasListItem = false;
continue;
}
flushList();
flushParagraph();
lastLineWasListItem = false;
continue;
}
if (trimmed.startsWith('@@CODEBLOCK')) {
flushList();
flushParagraph();
html += trimmed;
lastLineWasListItem = false;
continue;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
flushList();
flushParagraph();
const level = headingMatch[1].length;
html += `<h${level}>${inlineMarkdownToHtml(headingMatch[2])}</h${level}>`;
lastLineWasListItem = false;
continue;
}
const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/);
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
if (unorderedMatch || orderedMatch) {
flushParagraph();
detailBreak = false;
const type = orderedMatch ? 'ol' : 'ul';
let value = (orderedMatch ? orderedMatch[1] : unorderedMatch[1]) || '';
if (orderedMatch) {
value = value.replace(/^\d+\.\s+/, '');
}
if (!orderedMatch && list && list.type === 'ol' && list.items.length > 0) {
list.items[list.items.length - 1].children.push(value);
continue;
}
if (!list || list.type !== type) {
flushList();
list = { type, items: [] };
}
addListItem(list, value);
continue;
}
if (list && list.type === 'ol' && (lastLineWasListItem || detailBreak)) {
addDetailToLastItem(list, trimmed, detailBreak);
detailBreak = false;
continue;
}
if (list) {
flushList();
}
paragraph.push(trimmed);
lastLineWasListItem = false;
}
flushList();
flushParagraph();
codeBlocks.forEach((block, index) => {
html = html.replace(`@@CODEBLOCK${index}@@`, block);
});
return html;
};
const renderMessageContent = (content, allowMarkdown) => {
if (!allowMarkdown) {
return normalizeMessageContent(content);
}
return wp.element.createElement(RawHTML, null, markdownToHtml(content));
};
const lastActiveTimelineIndex = findLastActiveTimelineIndex(messages);
const groups = [];
let currentAiGroup = null;
messages.forEach((message, index) => {
if (message.role === 'user') {
groups.push({ type: 'user', message, key: `user-${index}` });
currentAiGroup = null;
return;
}
if (!currentAiGroup) {
currentAiGroup = { type: 'ai', items: [], key: `ai-${index}` };
groups.push(currentAiGroup);
}
currentAiGroup.items.push({ message, index });
});
return groups.map((group, groupIndex) => {
if (group.type === 'user') {
return wp.element.createElement('div', {
key: group.key,
className: 'wpaw-message wpaw-message-user',
},
wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(group.message.content, false))
);
}
const isLastGroup = groupIndex === groups.length - 1;
let streamingLabel = 'Streaming...';
for (let i = group.items.length - 1; i >= 0; i--) {
const item = group.items[i].message;
if (item.type === 'timeline' && item.status) {
if (item.status === 'checking') {
streamingLabel = 'Analyzing...';
} else if (item.status === 'planning' || item.status === 'plan_complete') {
streamingLabel = 'Planning...';
} else if (item.status === 'writing' || item.status === 'writing_section') {
streamingLabel = 'Writing...';
} else if (item.status === 'refining') {
streamingLabel = 'Refining...';
} else {
streamingLabel = 'Streaming...';
}
break;
}
}
return wp.element.createElement('div', {
key: group.key,
className: 'wpaw-ai-response',
},
group.items.map((item, itemIndex) => {
const message = item.message;
const index = item.index;
const isLastItem = itemIndex === group.items.length - 1;
if (message.type === 'timeline') {
const statusClass = message.status === 'complete'
? 'complete'
: message.status === 'inactive'
? 'inactive'
: 'active';
const showProcessing = isLoading && message.status === 'refining';
const elapsedTime = message.status === 'complete' && message.timestamp && message.completedAt
? ((new Date(message.completedAt) - new Date(message.timestamp)) / 1000).toFixed(1) + 's'
: null;
return wp.element.createElement('div', {
key: `timeline-${index}`,
className: 'wpaw-ai-item wpaw-timeline-entry ' + statusClass + (index === lastActiveTimelineIndex ? ' is-current' : ''),
},
wp.element.createElement('div', { className: 'wpaw-timeline-dot', 'aria-hidden': 'true' }),
wp.element.createElement('div', { className: 'wpaw-timeline-content' },
wp.element.createElement('div', { className: 'wpaw-timeline-message' }, normalizeMessageContent(message.message)),
message.status === 'complete' && wp.element.createElement('div', { className: 'wpaw-timeline-complete' },
'✓ Complete',
elapsedTime && wp.element.createElement('span', { className: 'wpaw-timeline-elapsed' }, ` (${elapsedTime})`)
),
showProcessing && wp.element.createElement('div', { className: 'wpaw-processing-indicator' },
wp.element.createElement('span', { className: 'wpaw-dots-loader' }),
wp.element.createElement('span', null, 'Processing updates…')
),
!showProcessing && isLoading && isLastGroup && isLastItem && wp.element.createElement('div', {
className: 'wpaw-streaming-indicator',
}, streamingLabel)
)
);
}
if (message.type === 'plan') {
const plan = ensurePlanTasks(message.plan);
const sections = Array.isArray(plan?.sections) ? plan.sections : [];
const getSectionSummary = (section) => {
if (section.description) {
return section.description;
}
if (Array.isArray(section.content) && section.content.length > 0) {
const firstItem = section.content.find((item) => item && item.content);
return firstItem ? firstItem.content : '';
}
return '';
};
const pendingCount = sections.filter((section) => section.status !== 'done').length;
const buttonLabel = pendingCount ? `Write ${pendingCount} Pending` : 'Write Article';
// Build config summary
const configSummary = [];
const languageLabel = postConfig.language === 'auto' ? 'Auto-detect' :
postConfig.language.charAt(0).toUpperCase() + postConfig.language.slice(1);
configSummary.push(`🌍 Language: ${languageLabel}`);
const lengthLabels = { short: 'Short (~800 words)', medium: 'Medium (~1500 words)', long: 'Long (~2500 words)' };
configSummary.push(`📏 Length: ${lengthLabels[postConfig.article_length] || 'Medium'}`);
if (postConfig.audience) {
configSummary.push(`👥 Audience: ${postConfig.audience}`);
}
if (postConfig.web_search) {
configSummary.push('🔍 Web Search: Enabled');
}
if (postConfig.seo_enabled) {
const seoDetails = [];
if (postConfig.seo_focus_keyword) {
seoDetails.push(`Focus: "${postConfig.seo_focus_keyword}"`);
}
if (postConfig.seo_secondary_keywords) {
seoDetails.push(`Secondary: "${postConfig.seo_secondary_keywords}"`);
}
configSummary.push(`📊 SEO: Enabled${seoDetails.length ? ' (' + seoDetails.join(', ') + ')' : ''}`);
}
return wp.element.createElement('div', {
key: `plan-${index}`,
className: 'wpaw-ai-item wpaw-plan-card',
},
if (message.type === 'edit_plan') {
const plan = message.plan || pendingEditPlan;
const isPlanActive = Boolean(pendingEditPlan) && plan === pendingEditPlan;
const actions = normalizePlanActions(plan);
const allBlocks = select('core/block-editor').getBlocks();
const existingIds = new Set(allBlocks.map((block) => block.clientId));
const previewActions = actions.filter((action) => {
if (action.action === 'keep') {
return false;
}
if (action.blockId && !existingIds.has(action.blockId)) {
return false;
}
return true;
});
const actionCount = previewActions.length;
const summary = plan?.summary || `Proposed changes: ${actionCount}`;
const previewItems = previewActions.map((action, actionIndex) => (
buildPlanPreviewItem(action, actionIndex)
));
return wp.element.createElement('div', {
key: `plan-${index}`,
className: 'wpaw-ai-item wpaw-edit-plan',
},
wp.element.createElement('div', { className: 'wpaw-edit-plan-title' }, 'Proposed Changes'),
wp.element.createElement('div', { className: 'wpaw-edit-plan-summary' }, summary),
previewItems.length > 0 && wp.element.createElement('div', { className: 'wpaw-edit-plan-preview-label' }, 'Apply preview'),
previewItems.length > 0 && wp.element.createElement('ol', { className: 'wpaw-edit-plan-list' },
previewItems.map((item, itemIndex) => wp.element.createElement('li', {
key: `plan-action-${itemIndex}`,
className: 'wpaw-edit-plan-item',
},
wp.element.createElement('div', { className: 'wpaw-edit-plan-item-title' }, item.title),
item.target && wp.element.createElement('button', {
type: 'button',
className: 'wpaw-edit-plan-item-target',
disabled: !isPlanActive,
onClick: () => {
if (!isPlanActive || !item.blockId) {
return;
}
dispatch('core/block-editor').selectBlock(item.blockId);
const targetNode = document.querySelector(`[data-block="${item.blockId}"]`);
if (targetNode) {
targetNode.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
},
}, `${item.targetLabel} ${item.target}`),
item.before && wp.element.createElement('div', { className: 'wpaw-edit-plan-item-before' }, `Before ${item.before}`),
item.after && wp.element.createElement('div', { className: 'wpaw-edit-plan-item-after' }, `Add ${item.after}`)
))
),
wp.element.createElement('div', { className: 'wpaw-edit-plan-actions' },
wp.element.createElement(Button, {
isPrimary: true,
onClick: () => applyEditPlan(plan),
disabled: !plan || !isPlanActive
}, `Apply (${actionCount})`),
wp.element.createElement(Button, {
isSecondary: true,
onClick: cancelEditPlan,
disabled: !isPlanActive
}, 'Cancel')
)
);
}
if (message.type === 'error') {
const handleRetry = () => {
if (message.retryType === 'execute') {
retryLastExecute();
return;
}
if (message.retryType === 'refine') {
retryLastRefinement();
return;
}
if (message.retryType === 'chat') {
retryLastChat();
return;
}
retryLastGeneration();
};
return wp.element.createElement('div', {
key: `error-${index}`,
className: 'wpaw-ai-item wpaw-message wpaw-message-error',
},
wp.element.createElement('div', { className: 'wpaw-message-content' }, renderMessageContent(message.content, true)),
message.canRetry && wp.element.createElement(Button, {
isSecondary: true,
onClick: handleRetry,
}, 'Retry')
);
}
return wp.element.createElement('div', {
key: `response-${index}`,
className: 'wpaw-ai-item wpaw-response',
},
wp.element.createElement('div', { className: 'wpaw-response-content' }, renderMessageContent(message.content, true)),
isLoading && isLastGroup && isLastItem && wp.element.createElement('div', {
className: 'wpaw-streaming-indicator',
}, streamingLabel)
);
})
);
});
};
// Render Config Tab
// Render Config Tab - Updated for Dark Theme
const renderConfigTab = () => {
const isConfigDisabled = isLoading || isConfigLoading || isConfigSaving;
return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-config-tab dark-theme' },
// Back Header
wp.element.createElement('div', { className: 'wpaw-tab-header' },
wp.element.createElement('button', {
className: 'wpaw-back-btn',
onClick: () => setActiveTab('chat')
}, '← Back'),
wp.element.createElement('h3', null, 'CONFIGURATION')
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement('label', null, 'DEFAULT MODE'),
wp.element.createElement('select', {
value: postConfig.default_mode,
onChange: (e) => {
updatePostConfig('default_mode', e.target.value);
setAgentMode(e.target.value);
},
disabled: isConfigDisabled,
className: 'wpaw-select'
},
wp.element.createElement('option', { value: 'writing' }, 'Writing'),
wp.element.createElement('option', { value: 'planning' }, 'Planning'),
wp.element.createElement('option', { value: 'chat' }, 'Chat')
),
wp.element.createElement('p', { className: 'description' },
'Controls which mode opens by default for this post.'
)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement('label', null, 'ARTICLE LENGTH'),
wp.element.createElement('select', {
value: postConfig.article_length,
onChange: (e) => updatePostConfig('article_length', e.target.value),
disabled: isConfigDisabled,
className: 'wpaw-select'
},
wp.element.createElement('option', { value: 'short' }, 'Short (500-800 words)'),
wp.element.createElement('option', { value: 'medium' }, 'Medium (800-1500 words)'),
wp.element.createElement('option', { value: 'long' }, 'Long (1500-2500 words)')
),
wp.element.createElement('p', { className: 'description' },
'Controls the length and depth of the generated article.'
)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement('label', null, 'Language'),
wp.element.createElement('select', {
value: postConfig.language,
onChange: (e) => updatePostConfig('language', e.target.value),
disabled: isConfigDisabled,
className: 'wpaw-select'
},
wp.element.createElement('option', { value: 'auto' }, 'Auto-detect'),
wp.element.createElement('option', { value: 'english' }, 'English'),
wp.element.createElement('option', { value: 'indonesian' }, 'Indonesian'),
wp.element.createElement('option', { value: 'spanish' }, 'Spanish'),
wp.element.createElement('option', { value: 'french' }, 'French')
),
wp.element.createElement('p', { className: 'description' },
'Overrides the detected language when writing or refining.'
)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(TextControl, {
label: 'Tone',
value: postConfig.tone,
onChange: (value) => updatePostConfig('tone', value),
disabled: isConfigDisabled,
placeholder: 'e.g., Friendly, persuasive, professional',
}),
wp.element.createElement('p', { className: 'description' },
'Use this to consistently guide the writing tone.'
)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(TextControl, {
label: 'Target Audience',
value: postConfig.audience,
onChange: (value) => updatePostConfig('audience', value),
disabled: isConfigDisabled,
placeholder: 'e.g., UMKM owners, beginners, marketers',
}),
wp.element.createElement('p', { className: 'description' },
'Helps the agent align examples and vocabulary.'
)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement('label', null, 'Experience Level'),
wp.element.createElement('select', {
value: postConfig.experience_level,
onChange: (e) => updatePostConfig('experience_level', e.target.value),
disabled: isConfigDisabled,
className: 'wpaw-select'
},
wp.element.createElement('option', { value: 'general' }, 'General audience'),
wp.element.createElement('option', { value: 'beginner' }, 'Beginner'),
wp.element.createElement('option', { value: 'intermediate' }, 'Intermediate'),
wp.element.createElement('option', { value: 'advanced' }, 'Advanced')
)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(CheckboxControl, {
label: 'Include image suggestions',
checked: Boolean(postConfig.include_images),
onChange: (value) => updatePostConfig('include_images', value),
disabled: isConfigDisabled,
}),
wp.element.createElement('p', { className: 'description' },
'When enabled, the agent will add image placeholders.'
)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(CheckboxControl, {
label: 'Enable web search for outlines',
checked: Boolean(postConfig.web_search),
onChange: (value) => updatePostConfig('web_search', value),
disabled: isConfigDisabled,
}),
wp.element.createElement('p', { className: 'description' },
'Uses web search when planning outlines.'
)
),
// SEO Section
wp.element.createElement('div', { className: 'wpaw-config-divider' },
wp.element.createElement('span', null, '🔍 SEO OPTIMIZATION')
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(CheckboxControl, {
label: 'Enable SEO optimization',
checked: Boolean(postConfig.seo_enabled),
onChange: (value) => updatePostConfig('seo_enabled', value),
disabled: isConfigDisabled,
}),
wp.element.createElement('p', { className: 'description' },
'Include SEO guidelines in AI prompts for keyword-optimized content.'
)
),
postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(TextControl, {
label: 'Focus Keyword',
value: postConfig.seo_focus_keyword,
onChange: (value) => updatePostConfig('seo_focus_keyword', value),
disabled: isConfigDisabled,
placeholder: 'e.g., wordpress seo plugin',
}),
wp.element.createElement('p', { className: 'description' },
'Primary keyword to optimize content for. Will be included in title, headings, and body.'
)
),
postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(TextControl, {
label: 'Secondary Keywords',
value: postConfig.seo_secondary_keywords,
onChange: (value) => updatePostConfig('seo_secondary_keywords', value),
disabled: isConfigDisabled,
placeholder: 'e.g., content optimization, search ranking',
}),
wp.element.createElement('p', { className: 'description' },
'Comma-separated related keywords to sprinkle throughout content.'
)
),
postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement(TextareaControl, {
label: 'Meta Description',
value: postConfig.seo_meta_description,
onChange: (value) => updatePostConfig('seo_meta_description', value),
disabled: isConfigDisabled,
placeholder: 'Enter meta description (120-160 chars recommended)',
rows: 3,
}),
wp.element.createElement('div', { className: 'wpaw-meta-info' },
wp.element.createElement('span', {
className: (postConfig.seo_meta_description?.length || 0) >= 120 && (postConfig.seo_meta_description?.length || 0) <= 160 ? 'good' : 'warning'
}, `${postConfig.seo_meta_description?.length || 0}/160 chars`),
wp.element.createElement(Button, {
isSecondary: true,
isSmall: true,
onClick: () => generateMetaDescription(),
disabled: isConfigDisabled || isGeneratingMeta,
},
isGeneratingMeta ?
wp.element.createElement('span', {
style: { display: 'flex', alignItems: 'center', gap: '5px' }
},
wp.element.createElement('span', {
className: 'wpaw-spinning-icon',
dangerouslySetInnerHTML: {
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>'
}
}),
' Generating...'
) :
wp.element.createElement('span', {
style: { display: 'flex', alignItems: 'center', gap: '5px' }
},
wp.element.createElement('span', {
className: 'wpaw-svg-wrapper',
dangerouslySetInnerHTML: {
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#fbbf24" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594zM20 2v4m2-2h-4"/><circle cx="4" cy="20" r="2"/></g></svg>'
}
}),
' Generate'
)
)
)
),
// SEO Audit Section
postConfig.seo_enabled && wp.element.createElement('div', { className: 'wpaw-config-section wpaw-seo-audit' },
wp.element.createElement('div', { className: 'wpaw-seo-audit-header' },
wp.element.createElement('label', null, 'SEO Audit'),
wp.element.createElement(Button, {
isSecondary: true,
isSmall: true,
onClick: () => runSeoAudit(),
disabled: isConfigDisabled || isSeoAuditing,
},
isSeoAuditing ?
wp.element.createElement('span', {
style: { display: 'flex', alignItems: 'center', gap: '5px' }
},
wp.element.createElement('span', {
className: 'wpaw-spinning-icon',
style: { display: 'inline-flex', lineHeight: '0' },
dangerouslySetInnerHTML: {
// Icon Loader/Circle-slashed untuk kesan analyzing
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4ade80" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>'
}
}),
' Analyzing...'
) :
wp.element.createElement('span', {
style: { display: 'flex', alignItems: 'center', gap: '5px' }
},
wp.element.createElement('span', {
className: 'wpaw-svg-wrapper',
style: { display: 'inline-flex', lineHeight: '0' },
dangerouslySetInnerHTML: {
// Icon Bar-Chart untuk "Run Audit"
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#4ade80" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M13 5h8m-8 7h8m-8 7h8M3 17l2 2l4-4M3 7l2 2l4-4"/></svg>'
}
}),
' Run Audit'
)
)
),
seoAudit && wp.element.createElement('div', { className: 'wpaw-seo-audit-results' },
wp.element.createElement('div', { className: 'wpaw-seo-score ' + (seoAudit.score >= 70 ? 'good' : seoAudit.score >= 40 ? 'warning' : 'poor') },
wp.element.createElement('span', { className: 'score-value' }, seoAudit.score),
wp.element.createElement('span', { className: 'score-label' }, '/100')
),
wp.element.createElement('div', { className: 'wpaw-seo-stats' },
wp.element.createElement('div', { className: 'wpaw-seo-stat' },
wp.element.createElement('span', { className: 'stat-label' }, 'Words'),
wp.element.createElement('span', { className: 'stat-value' }, seoAudit.word_count || 0)
),
wp.element.createElement('div', { className: 'wpaw-seo-stat' },
wp.element.createElement('span', { className: 'stat-label' }, 'Keyword Density'),
wp.element.createElement('span', { className: 'stat-value' }, `${(seoAudit.keyword_density || 0).toFixed(1)}%`)
)
),
seoAudit.checks && wp.element.createElement('div', { className: 'wpaw-seo-checks' },
seoAudit.checks.map((check, idx) => {
const isPassed = check.status === 'good' || check.status === 'ok';
return wp.element.createElement('div', {
key: idx,
className: 'wpaw-seo-check ' + (isPassed ? 'passed' : 'failed')
},
wp.element.createElement('span', { className: 'check-icon' }, isPassed ? '✓' : '✗'),
wp.element.createElement('span', { className: 'check-label' }, check.message)
);
})
)
),
!seoAudit && wp.element.createElement('p', { className: 'description' },
'Click "Run Audit" to analyze your content for SEO optimization.'
)
),
(isConfigSaving || configError) && wp.element.createElement('div', { className: 'wpaw-config-section' },
isConfigSaving && wp.element.createElement('p', { className: 'description' }, 'Saving post configuration...'),
configError && wp.element.createElement('p', { className: 'description' }, configError)
),
wp.element.createElement('div', { className: 'wpaw-config-section' },
wp.element.createElement('p', { className: 'description' },
'Configure global settings like API keys, models, and clarification quiz options in ',
wp.element.createElement('a', {
href: settings.settings_url || '/wp-admin/options-general.php?page=wp-agentic-writer',
target: '_blank'
}, 'Settings → WP Agentic Writer')
)
)
);
};
// Render Chat Tab
const renderChatTab = () => {
// Determine agent status
const getAgentStatus = () => {
if (!isLoading) return 'idle';
const lastMsg = messages.filter(m => m.type === 'timeline').pop();
if (lastMsg?.message?.toLowerCase().includes('writing')) return 'writing';
if (lastMsg?.message?.toLowerCase().includes('generating')) return 'writing';
return 'thinking';
};
const agentStatus = getAgentStatus();
const statusLabels = { idle: 'Ready', thinking: 'Thinking...', writing: 'Writing...', complete: 'Done', error: 'Error' };
return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-chat-tab dark-theme' },
renderClarification(),
!inClarification && wp.element.createElement('div', { className: 'wpaw-chat-container' },
// Status Bar
wp.element.createElement('div', { className: 'wpaw-status-bar' },
wp.element.createElement('div', { className: 'wpaw-status-indicator' },
wp.element.createElement('span', { className: 'wpaw-status-dot ' + agentStatus }),
wp.element.createElement('span', { className: 'wpaw-status-label' }, statusLabels[agentStatus])
),
wp.element.createElement('div', { className: 'wpaw-status-actions' },
// Undo Button
aiUndoStack.length > 0 && wp.element.createElement('button', {
className: 'wpaw-status-icon-btn wpaw-undo-btn',
title: `Undo: ${aiUndoStack[aiUndoStack.length - 1]?.label || 'Last AI operation'}`,
onClick: undoLastAiOperation,
disabled: isLoading
}, '↩️'),
// Cost Label
wp.element.createElement('span', { className: 'wpaw-status-cost' },
'Session: $' + cost.session.toFixed(4)
),
// Config Icon Button
wp.element.createElement('button', {
className: 'wpaw-status-icon-btn',
dangerouslySetInnerHTML: { __html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M14 17H5M19 7h-9"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></g></svg>' },
title: 'Configuration',
onClick: () => setActiveTab('config')
}),
// Cost Icon Button
wp.element.createElement('button', {
className: 'wpaw-status-icon-btn',
dangerouslySetInnerHTML: { __html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/></g></svg>' },
title: 'Cost Tracking',
onClick: () => setActiveTab('cost')
})
)
),
// Editor Lock Banner
isEditorLocked && wp.element.createElement('div', { className: 'wpaw-editor-lock-banner' },
'Writing in progress — please wait until the article finishes.'
),
// Activity Log
wp.element.createElement('div', { className: 'wpaw-messages wpaw-activity-log' },
wp.element.createElement('div', { className: 'wpaw-messages-inner', ref: messagesContainerRef },
renderMessages(),
wp.element.createElement('div', { ref: messagesEndRef })
)
),
// Command Input Area
wp.element.createElement('div', { className: 'wpaw-command-area', style: { position: 'relative' } },
// Removed Toolbar from Top
wp.element.createElement('div', { className: 'wpaw-command-input-wrapper' },
wp.element.createElement('span', { className: 'wpaw-command-prefix' }, '>'),
wp.element.createElement(TextareaControl, {
ref: inputRef,
value: input,
onChange: handleInputChange,
onKeyDown: handleKeyDown,
placeholder: agentMode === 'planning'
? 'Describe what you want to write about...'
: agentMode === 'chat'
? 'Ask me anything about your content...'
: 'Tell me what to write. Use @block to refine.',
})
),
showMentionAutocomplete && mentionOptions.length > 0 && wp.element.createElement('div', {
className: 'wpaw-mention-autocomplete',
style: {
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
maxHeight: '200px',
overflowY: 'auto',
background: '#1e1e1e',
border: '1px solid #3c3c3c',
zIndex: 1000
}
},
mentionOptions.map((option, index) => {
const isSelected = index === mentionCursorIndex;
return wp.element.createElement('div', {
key: option.id,
className: 'wpaw-mention-option' + (isSelected ? ' selected' : ''),
onClick: () => insertMention(option),
style: {
padding: '8px 12px',
cursor: 'pointer',
background: isSelected ? '#2c2c2c' : 'transparent',
borderBottom: '1px solid #3c3c3c'
}
},
wp.element.createElement('strong', {
style: { display: 'block', color: '#fff', fontSize: '13px' }
}, option.label),
wp.element.createElement('span', {
style: { display: 'block', color: '#a7aaad', fontSize: '12px', marginTop: '2px' }
}, option.sublabel)
);
})
),
showSlashAutocomplete && slashOptions.length > 0 && wp.element.createElement('div', {
className: 'wpaw-mention-autocomplete',
style: {
position: 'absolute',
bottom: '100%',
left: 0,
right: 0,
maxHeight: '200px',
overflowY: 'auto',
background: '#1e1e1e',
border: '1px solid #3c3c3c',
zIndex: 1000
}
},
slashOptions.map((option, index) => {
const isSelected = index === slashCursorIndex;
return wp.element.createElement('div', {
key: option.id,
className: 'wpaw-mention-option' + (isSelected ? ' selected' : ''),
onClick: () => insertSlashCommand(option),
style: {
padding: '8px 12px',
cursor: 'pointer',
background: isSelected ? '#2c2c2c' : 'transparent',
borderBottom: '1px solid #3c3c3c'
}
},
wp.element.createElement('strong', {
style: { display: 'block', color: '#fff', fontSize: '13px' }
}, option.label),
wp.element.createElement('span', {
style: { display: 'block', color: '#a7aaad', fontSize: '12px', marginTop: '2px' }
}, option.sublabel)
);
})
),
wp.element.createElement('div', { className: 'wpaw-command-actions' },
wp.element.createElement('div', { className: 'wpaw-command-actions-group' },
// Mode Selector (Bottom Left)
wp.element.createElement('div', { className: 'wpaw-command-mode-wrapper' },
wp.element.createElement('span', { className: 'wpaw-command-label' }, 'MODE:'),
wp.element.createElement('select', {
className: 'wpaw-command-mode-select',
id: 'agentMode',
value: agentMode,
onChange: (e) => setAgentMode(e.target.value),
disabled: isLoading,
},
wp.element.createElement('option', { value: 'writing' }, 'Writing'),
wp.element.createElement('option', { value: 'planning' }, 'Planning'),
wp.element.createElement('option', { value: 'chat' }, 'Chat')
)
),
// Web Search Toggle (next to mode)
wp.element.createElement('label', {
className: 'wpaw-web-search-toggle',
title: 'Enable web search for current data (costs ~$0.02/search)',
},
wp.element.createElement('input', {
type: 'checkbox',
checked: postConfig.web_search || false,
onChange: (e) => updatePostConfig('web_search', e.target.checked),
disabled: isLoading,
}),
wp.element.createElement('span', {
className: 'wpaw-web-search-icon',
dangerouslySetInnerHTML: { __html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20a14.5 14.5 0 0 0 0-20M2 12h20"/></g></svg>' }
}),
wp.element.createElement('span', { className: 'wpaw-web-search-label' }, 'Search')
),
),
wp.element.createElement('div', { className: 'wpaw-command-actions-group' },
// Clear Context (Bottom Middle-ish)
wp.element.createElement('button', {
className: 'wpaw-command-text-btn',
type: 'button',
onClick: clearChatContext,
disabled: isLoading,
}, 'Clear Context'),
// Execute Button (Bottom Right)
wp.element.createElement('button', {
className: 'wpaw-command-btn',
onClick: sendMessage,
disabled: isLoading || !input.trim(),
}, isLoading ? 'Executing...' : 'Send')
)
)
)
)
);
};
// Refresh cost data from server
const [costHistory, setCostHistory] = wp.element.useState([]);
const refreshCostData = async () => {
if (!postId) return;
try {
const response = await fetch(`${wpAgenticWriter.apiUrl}/cost-tracking/${postId}`, {
headers: { 'X-WP-Nonce': wpAgenticWriter.nonce },
});
const data = await response.json();
if (data && typeof data.session === 'number') {
setCost({
session: data.session,
today: data.today?.total?.cost || 0,
monthlyUsed: data.monthly?.used || 0,
});
}
if (data?.monthly?.budget) {
setMonthlyBudget(data.monthly.budget);
}
if (data?.history) {
setCostHistory(data.history);
}
} catch (e) {
console.error('Failed to refresh cost data:', e);
}
};
// Render Cost Tab
const renderCostTab = () => {
const budgetPercent = monthlyBudget > 0 ? (cost.monthlyUsed / monthlyBudget) * 100 : 0;
const budgetStatus = budgetPercent > 90 ? 'danger' : budgetPercent > 70 ? 'warning' : 'ok';
const remaining = Math.max(0, monthlyBudget - cost.monthlyUsed);
return wp.element.createElement('div', { className: 'wpaw-tab-content wpaw-cost-tab dark-theme' },
wp.element.createElement('div', { className: 'wpaw-tab-header' },
wp.element.createElement('button', {
className: 'wpaw-back-btn',
onClick: () => setActiveTab('chat')
}, '← Back'),
wp.element.createElement('h3', null, 'COST TRACKING'),
wp.element.createElement('button', {
className: 'wpaw-refresh-btn',
dangerouslySetInnerHTML: { __html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M21 12a9 9 0 0 0-9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5m-5 4a9 9 0 0 0 9 9a9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></g></svg>' },
onClick: refreshCostData,
title: 'Refresh cost data'
})
),
wp.element.createElement('div', { className: 'wpaw-cost-card' },
wp.element.createElement('div', { className: 'wpaw-cost-stat' },
wp.element.createElement('label', null, 'This Post'),
wp.element.createElement('div', { className: 'wpaw-cost-value' },
'$', cost.session.toFixed(4)
)
),
wp.element.createElement('div', { className: 'wpaw-cost-stat' },
wp.element.createElement('label', null, 'Month Used'),
wp.element.createElement('div', { className: 'wpaw-cost-value' },
'$', cost.monthlyUsed.toFixed(4)
)
),
wp.element.createElement('div', { className: 'wpaw-cost-stat wpaw-cost-remaining' },
wp.element.createElement('label', null, 'Remaining'),
wp.element.createElement('div', { className: 'wpaw-cost-value ' + budgetStatus },
'$', remaining.toFixed(2)
)
)
),
wp.element.createElement('div', { className: 'wpaw-budget-section' },
wp.element.createElement('div', { className: 'wpaw-budget-label' },
wp.element.createElement('span', null, 'Budget: $', monthlyBudget.toFixed(2)),
wp.element.createElement('span', null, budgetPercent.toFixed(1), '%')
),
wp.element.createElement('div', { className: 'wpaw-budget-bar' },
wp.element.createElement('div', {
className: 'wpaw-budget-fill ' + budgetStatus,
style: { width: Math.min(budgetPercent, 100) + '%' }
})
)
),
budgetPercent > 80 && wp.element.createElement('div', {
className: 'wpaw-budget-warning ' + budgetStatus,
}, budgetPercent >= 100 ? '⚠️ Budget exceeded!' : '⚠️ Approaching budget limit'),
costHistory.length > 0 && wp.element.createElement('div', { className: 'wpaw-cost-history' },
wp.element.createElement('h4', null, 'Cost History'),
wp.element.createElement('div', { className: 'wpaw-cost-table-wrapper' },
wp.element.createElement('table', { className: 'wpaw-cost-table' },
wp.element.createElement('thead', null,
wp.element.createElement('tr', null,
wp.element.createElement('th', null, 'Time'),
wp.element.createElement('th', null, 'Action'),
wp.element.createElement('th', null, 'Model'),
wp.element.createElement('th', null, 'Tokens'),
wp.element.createElement('th', null, 'Cost')
)
),
wp.element.createElement('tbody', null,
costHistory.map((record, idx) => {
const totalTokens = parseInt(record.input_tokens || 0) + parseInt(record.output_tokens || 0);
const time = new Date(record.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
const modelShort = record.model ? record.model.split('/').pop().substring(0, 20) : 'N/A';
return wp.element.createElement('tr', { key: idx },
wp.element.createElement('td', null, time),
wp.element.createElement('td', null, record.action),
wp.element.createElement('td', { title: record.model }, modelShort),
wp.element.createElement('td', null, totalTokens.toLocaleString()),
wp.element.createElement('td', null, '$' + parseFloat(record.cost).toFixed(4))
);
})
)
)
)
),
wp.element.createElement('div', { className: 'wpaw-cost-footer' },
wp.element.createElement('a', {
href: settings.settings_url || '/wp-admin/options-general.php?page=wp-agentic-writer',
target: '_blank',
className: 'wpaw-cost-settings-link'
},
wp.element.createElement('span', {
dangerouslySetInnerHTML: { __html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="margin-bottom: -7px;"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0a2.34 2.34 0 0 0 3.319 1.915a2.34 2.34 0 0 1 2.33 4.033a2.34 2.34 0 0 0 0 3.831a2.34 2.34 0 0 1-2.33 4.033a2.34 2.34 0 0 0-3.319 1.915a2.34 2.34 0 0 1-4.659 0a2.34 2.34 0 0 0-3.32-1.915a2.34 2.34 0 0 1-2.33-4.033a2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></g></svg> Manage Budget Settings' }
}),
)
)
);
};
// Main render.
return wp.element.createElement(PluginSidebar, { name: 'wp-agentic-writer', title: 'WP Agentic Writer' },
wp.element.createElement(Panel, null,
wp.element.createElement('div', { className: 'wpaw-tab-content-wrapper' },
activeTab === 'chat' && renderChatTab(),
activeTab === 'config' && renderConfigTab(),
activeTab === 'cost' && renderCostTab()
)
)
);
};
// HOC to get post ID.
const mapSelectToProps = (select) => ({
postId: select('core/editor').getCurrentPostId(),
});
// Connect sidebar to Redux store.
const ConnectedSidebar = wp.data.withSelect(mapSelectToProps)(AgenticWriterSidebar);
// Register plugin.
registerPlugin('wp-agentic-writer', {
icon: 'edit',
render: ConnectedSidebar,
});
})(window.wp);