5694 lines
192 KiB
Plaintext
5694 lines
192 KiB
Plaintext
/**
|
||
* 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);
|
||
const stopExecutionRef = React.useRef(false);
|
||
const [executionStopped, setExecutionStopped] = React.useState(false);
|
||
|
||
// 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 [isTextareaExpanded, setIsTextareaExpanded] = React.useState(false);
|
||
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,
|
||
chatHistory: messages.filter(m => m.role !== 'system'),
|
||
}),
|
||
});
|
||
|
||
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;
|
||
};
|
||
|
||
// Check if Writing mode needs empty state
|
||
const shouldShowWritingEmptyState = () => {
|
||
if (agentMode !== 'writing') return false;
|
||
if (currentPlanRef.current) return false;
|
||
|
||
// Check if editor has content blocks
|
||
const allBlocks = select('core/block-editor').getBlocks();
|
||
const hasContent = allBlocks.length > 0;
|
||
|
||
// Only show empty state if no plan AND no content in editor
|
||
return !hasContent;
|
||
};
|
||
|
||
// Summarize chat history for token optimization
|
||
const summarizeChatHistory = async () => {
|
||
const chatMessages = messages.filter(m => m.role !== 'system');
|
||
|
||
if (chatMessages.length < 4) {
|
||
return { summary: '', useFullHistory: true, cost: 0 };
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(wpAgenticWriter.apiUrl + '/summarize-context', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-WP-Nonce': wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({
|
||
chatHistory: chatMessages,
|
||
postId: postId,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Summarization failed');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
if (data.tokens_saved > 0) {
|
||
console.log(`💡 Context optimized: ~${data.tokens_saved} tokens saved (~$${(data.tokens_saved * 0.0000002).toFixed(4)})`);
|
||
}
|
||
|
||
return {
|
||
summary: data.summary || '',
|
||
useFullHistory: data.use_full_history || false,
|
||
cost: data.cost || 0,
|
||
tokensSaved: data.tokens_saved || 0,
|
||
};
|
||
} catch (error) {
|
||
console.error('Summarization error:', error);
|
||
return { summary: '', useFullHistory: true, cost: 0 };
|
||
}
|
||
};
|
||
|
||
// Detect user intent for contextual actions
|
||
const detectUserIntent = async (lastMessage) => {
|
||
if (!lastMessage || lastMessage.trim().length === 0) {
|
||
return { intent: 'continue_chat', cost: 0 };
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(wpAgenticWriter.apiUrl + '/detect-intent', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-WP-Nonce': wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({
|
||
lastMessage: lastMessage,
|
||
hasPlan: Boolean(currentPlanRef.current),
|
||
currentMode: agentMode,
|
||
postId: postId,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Intent detection failed');
|
||
}
|
||
|
||
const data = await response.json();
|
||
return {
|
||
intent: data.intent || 'continue_chat',
|
||
cost: data.cost || 0,
|
||
};
|
||
} catch (error) {
|
||
console.error('Intent detection error:', error);
|
||
return { intent: 'continue_chat', cost: 0 };
|
||
}
|
||
};
|
||
|
||
// Build optimized context (full or summarized)
|
||
const buildOptimizedContext = async () => {
|
||
const result = await summarizeChatHistory();
|
||
|
||
if (result.useFullHistory) {
|
||
return {
|
||
type: 'full',
|
||
messages: messages.filter(m => m.role !== 'system'),
|
||
cost: 0,
|
||
};
|
||
}
|
||
|
||
return {
|
||
type: 'summary',
|
||
summary: result.summary,
|
||
cost: result.cost,
|
||
tokensSaved: result.tokensSaved,
|
||
};
|
||
};
|
||
|
||
// Handle reset/clear command
|
||
const handleResetCommand = async () => {
|
||
if (!confirm('Clear all conversation history? This cannot be undone.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Clear frontend state
|
||
setMessages([]);
|
||
currentPlanRef.current = null;
|
||
|
||
// Clear backend chat history
|
||
await fetch(wpAgenticWriter.apiUrl + '/clear-context', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-WP-Nonce': wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({ postId: postId }),
|
||
});
|
||
|
||
setMessages([{
|
||
role: 'system',
|
||
type: 'info',
|
||
content: '✅ Context cleared. Starting fresh conversation.'
|
||
}]);
|
||
} catch (error) {
|
||
console.error('Reset error:', error);
|
||
setMessages(prev => [...prev, {
|
||
role: 'system',
|
||
type: 'error',
|
||
content: 'Failed to clear context. Please try again.'
|
||
}]);
|
||
}
|
||
};
|
||
|
||
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;
|
||
});
|
||
|
||
// Auto-suggest keywords after outline is generated
|
||
if (agentMode === 'planning' && normalizedPlan) {
|
||
suggestKeywordsFromPlan(normalizedPlan);
|
||
}
|
||
};
|
||
|
||
const suggestKeywordsFromPlan = async (plan) => {
|
||
if (!plan || !plan.title || !plan.sections) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(wpAgenticWriter.apiUrl + '/suggest-keywords', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-WP-Nonce': wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({
|
||
postId: postId,
|
||
title: plan.title,
|
||
sections: plan.sections,
|
||
}),
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error('Failed to suggest keywords');
|
||
}
|
||
|
||
const data = await response.json();
|
||
|
||
// Update post config with suggested keywords
|
||
if (data.focus_keyword) {
|
||
updatePostConfig('seo_focus_keyword', data.focus_keyword);
|
||
}
|
||
if (data.secondary_keywords && Array.isArray(data.secondary_keywords)) {
|
||
updatePostConfig('seo_secondary_keywords', data.secondary_keywords.join(', '));
|
||
}
|
||
|
||
// Track cost
|
||
if (data.cost) {
|
||
setCost({ ...cost, session: cost.session + data.cost });
|
||
}
|
||
|
||
// Add assistant message about keyword suggestions
|
||
setMessages(prev => [...prev, {
|
||
role: 'assistant',
|
||
content: `🎯 **SEO Keywords Suggested:**\n\n**Focus Keyword:** ${data.focus_keyword}\n\n**Secondary Keywords:** ${data.secondary_keywords.join(', ')}\n\n${data.reasoning || ''}\n\nYou can review and edit these in the Config panel before writing.`
|
||
}]);
|
||
} catch (error) {
|
||
console.error('Keyword suggestion error:', error);
|
||
// Silently fail - don't interrupt the workflow
|
||
}
|
||
};
|
||
|
||
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;
|
||
}
|
||
|
||
// Check if plan exists
|
||
if (!currentPlanRef.current) {
|
||
setMessages(prev => [...prev, {
|
||
role: 'system',
|
||
type: 'error',
|
||
content: '⚠️ No outline found. Please create an outline first by switching to Planning mode.'
|
||
}]);
|
||
setIsLoading(false);
|
||
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,
|
||
chatHistory: messages.filter(m => m.role !== 'system'),
|
||
};
|
||
|
||
// Reset stop flag
|
||
stopExecutionRef.current = false;
|
||
setExecutionStopped(false);
|
||
|
||
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) {
|
||
// Check if execution should stop
|
||
if (stopExecutionRef.current) {
|
||
reader.cancel();
|
||
clearTimeout(timeout);
|
||
setExecutionStopped(true);
|
||
setIsLoading(false);
|
||
|
||
// Calculate completed sections
|
||
const plan = currentPlanRef.current;
|
||
const completedCount = plan?.sections?.filter(s => s.status === 'done').length || 0;
|
||
const totalCount = plan?.sections?.length || 0;
|
||
const pendingCount = totalCount - completedCount;
|
||
|
||
setMessages(prev => [...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: 'system',
|
||
type: 'timeline',
|
||
status: 'stopped',
|
||
message: `⏸️ Execution stopped (${completedCount}/${totalCount} sections completed)`,
|
||
timestamp: new Date()
|
||
},
|
||
{
|
||
role: 'assistant',
|
||
content: `**Execution Paused**\n\n✅ Completed: ${completedCount} section${completedCount !== 1 ? 's' : ''}\n⏳ Pending: ${pendingCount} section${pendingCount !== 1 ? 's' : ''}\n\nYour generated content has been preserved in the editor.`,
|
||
showResumeActions: true,
|
||
pendingCount: pendingCount
|
||
}
|
||
]);
|
||
break;
|
||
}
|
||
|
||
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);
|
||
// Check if execution should stop after section completes
|
||
if (stopExecutionRef.current) {
|
||
reader.cancel();
|
||
clearTimeout(timeout);
|
||
setExecutionStopped(true);
|
||
setIsLoading(false);
|
||
setMessages(prev => [...deactivateActiveTimelineEntries(prev), {
|
||
role: 'system',
|
||
type: 'timeline',
|
||
status: 'stopped',
|
||
message: '⏸️ Execution stopped by user',
|
||
timestamp: new Date()
|
||
}]);
|
||
break;
|
||
}
|
||
} else if (data.type === 'assistant_message') {
|
||
// Add assistant message to chat
|
||
setMessages(prev => [...prev, { role: 'assistant', content: data.message }]);
|
||
} 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 handleStopExecution = () => {
|
||
if (!isLoading) return;
|
||
|
||
stopExecutionRef.current = true;
|
||
};
|
||
|
||
const clearChatContext = async () => {
|
||
if (isLoading) {
|
||
return;
|
||
}
|
||
|
||
const confirmMessage = 'Clear chat context? This removes the current sidebar conversation and resets the agent’s chat memory (including stored chat history) for this post. It won’t change your article content or outline.';
|
||
if (!window.confirm(confirmMessage)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await fetch(wpAgenticWriter.apiUrl + '/clear-context', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-WP-Nonce': wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({ postId }),
|
||
});
|
||
setMessages([]);
|
||
setInClarification(false);
|
||
setQuestions([]);
|
||
setCurrentQuestionIndex(0);
|
||
setAnswers([]);
|
||
setPendingRefinement(null);
|
||
setPendingEditPlan(null);
|
||
streamTargetRef.current = null;
|
||
} catch (error) {
|
||
setMessages(prev => [...prev, {
|
||
role: 'system',
|
||
type: 'error',
|
||
content: 'Error: Failed to clear chat context.',
|
||
}]);
|
||
}
|
||
};
|
||
const createBlocksFromSerialized = (block) => {
|
||
if (!block || !block.blockName) {
|
||
return null;
|
||
}
|
||
|
||
const attrs = { ...(block.attrs || {}) };
|
||
|
||
// Handle code blocks
|
||
if (block.blockName === 'core/code' && !attrs.content && block.innerHTML) {
|
||
const match = block.innerHTML.match(/<code>([\s\S]*?)<\/code>/i);
|
||
if (match && match[1]) {
|
||
attrs.content = match[1]
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"');
|
||
}
|
||
}
|
||
|
||
// Handle table blocks - extract head and body from innerHTML
|
||
if (block.blockName === 'core/table' && block.innerHTML) {
|
||
const headMatch = block.innerHTML.match(/<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,
|
||
chatHistory: messages.filter(m => m.role !== 'system'),
|
||
}),
|
||
});
|
||
|
||
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();
|
||
// Collapse textarea to give more space for response
|
||
setIsTextareaExpanded(false);
|
||
|
||
// Check for reset command
|
||
if (/^\s*(\/reset|\/clear)\s*$/i.test(userMessage)) {
|
||
setInput('');
|
||
await handleResetCommand();
|
||
return;
|
||
}
|
||
|
||
// Check for Writing mode notes warning
|
||
if (agentMode === 'writing' && currentPlanRef.current) {
|
||
setInput('');
|
||
setMessages(prev => [...prev,
|
||
{ role: 'user', content: userMessage },
|
||
{
|
||
role: 'system',
|
||
type: 'info',
|
||
content: '💡 Note: Messages in Writing mode are for discussion only. To modify the outline, switch to Planning mode.'
|
||
}
|
||
]);
|
||
return;
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Detect intent after chat completes
|
||
try {
|
||
const intentResult = await detectUserIntent(userMessage);
|
||
|
||
// Track intent detection cost
|
||
if (intentResult.cost > 0) {
|
||
setCost(prev => ({ ...prev, session: prev.session + intentResult.cost }));
|
||
}
|
||
|
||
if (intentResult.intent && intentResult.intent !== 'continue_chat') {
|
||
setMessages(prev => {
|
||
const newMessages = [...prev];
|
||
const lastIdx = newMessages.length - 1;
|
||
if (newMessages[lastIdx] && newMessages[lastIdx].role === 'assistant') {
|
||
newMessages[lastIdx] = { ...newMessages[lastIdx], detectedIntent: intentResult.intent };
|
||
}
|
||
return newMessages;
|
||
});
|
||
}
|
||
} catch (intentError) {
|
||
console.error('Intent detection failed:', intentError);
|
||
}
|
||
} 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),
|
||
field.description && wp.element.createElement('span', { className: 'wpaw-config-description' }, field.description)
|
||
),
|
||
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 Writing mode empty state
|
||
const renderWritingEmptyState = () => {
|
||
return wp.element.createElement('div', { className: 'wpaw-writing-empty-state' },
|
||
wp.element.createElement('div', { className: 'wpaw-empty-state-content' },
|
||
wp.element.createElement('span', {
|
||
className: 'wpaw-empty-state-icon',
|
||
dangerouslySetInnerHTML: { __html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="width: 60px; height: 60px;"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12 5a3 3 0 1 0-5.997.125a4 4 0 0 0-2.526 5.77a4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M9 13a4.5 4.5 0 0 0 3-4M6.003 5.125A3 3 0 0 0 6.401 6.5m-2.924 4.396a4 4 0 0 1 .585-.396M6 18a4 4 0 0 1-1.967-.516M12 13h4m-4 5h6a2 2 0 0 1 2 2v1M12 8h8m-4 0V5a2 2 0 0 1 2-2"/><circle cx="16" cy="13" r=".5"/><circle cx="18" cy="3" r=".5"/><circle cx="20" cy="21" r=".5"/><circle cx="20" cy="8" r=".5"/></g></svg>' }
|
||
}),
|
||
wp.element.createElement('h3', null, 'No Outline Yet'),
|
||
wp.element.createElement('p', null, 'Writing mode requires an outline to structure your article.'),
|
||
wp.element.createElement(Button, {
|
||
isPrimary: true,
|
||
onClick: () => setAgentMode('planning'),
|
||
className: 'wpaw-empty-state-button'
|
||
},
|
||
wp.element.createElement('div', {
|
||
style: { display: 'inline-flex', alignItems: 'center', gap: '8px' }
|
||
},
|
||
wp.element.createElement('svg', {
|
||
xmlns: "http://www.w3.org/2000/svg",
|
||
width: "18",
|
||
height: "18",
|
||
viewBox: "0 0 24 24"
|
||
},
|
||
wp.element.createElement('path', {
|
||
fill: "none",
|
||
stroke: "currentColor",
|
||
strokeLinecap: "round",
|
||
strokeLinejoin: "round",
|
||
strokeWidth: "1",
|
||
d: "M16 5H3m13 7H3m8 7H3m12-1l2 2l4-4"
|
||
})
|
||
),
|
||
'Create Outline First'
|
||
)
|
||
),
|
||
wp.element.createElement('p', { className: 'wpaw-empty-state-hint' },
|
||
'Or switch to ',
|
||
wp.element.createElement('button', {
|
||
onClick: () => setAgentMode('chat'),
|
||
className: 'wpaw-link-button'
|
||
}, 'Chat mode'),
|
||
' to discuss your ideas.'
|
||
)
|
||
)
|
||
);
|
||
};
|
||
|
||
// Render context indicator
|
||
const renderContextIndicator = () => {
|
||
const chatMessages = messages.filter(m => m.role !== 'system');
|
||
const messageCount = chatMessages.length;
|
||
const estimatedTokens = messageCount * 500;
|
||
|
||
// if (messageCount === 0) return null;
|
||
|
||
return wp.element.createElement('div', { className: 'wpaw-context-indicator' },
|
||
wp.element.createElement('div', { className: 'wpaw-context-info' },
|
||
wp.element.createElement('span', { className: 'wpaw-context-count' },
|
||
`💬 ${messageCount} messages`
|
||
),
|
||
wp.element.createElement('span', { className: 'wpaw-context-tokens' },
|
||
`~${estimatedTokens} tokens`
|
||
),
|
||
wp.element.createElement('span', { className: 'wpaw-context-cost' },
|
||
`💰 $${cost.session.toFixed(4)}`
|
||
)
|
||
),
|
||
wp.element.createElement('button', {
|
||
className: 'wpaw-context-toggle',
|
||
onClick: () => setIsTextareaExpanded(!isTextareaExpanded),
|
||
title: isTextareaExpanded ? 'Collapse textarea' : 'Expand textarea'
|
||
},
|
||
wp.element.createElement('svg', {
|
||
xmlns: "http://www.w3.org/2000/svg",
|
||
width: "18",
|
||
height: "18",
|
||
viewBox: "0 0 24 24",
|
||
style: { verticalAlign: 'middle', marginBottom: '0' }
|
||
},
|
||
wp.element.createElement('path', {
|
||
fill: "none",
|
||
stroke: "currentColor",
|
||
strokeLinecap: "round",
|
||
strokeLinejoin: "round",
|
||
strokeWidth: "1",
|
||
d: isTextareaExpanded ? "m7 20l5-5l5 5M7 4l5 5l5-5" : "m7 15l5 5l5-5M7 9l5-5l5 5"
|
||
})
|
||
)
|
||
)
|
||
);
|
||
};
|
||
|
||
// Render contextual action card
|
||
const renderContextualAction = (intent) => {
|
||
if (!intent || intent === 'continue_chat') return null;
|
||
|
||
const actions = {
|
||
create_outline: {
|
||
icon: '📝',
|
||
title: 'Ready to create an outline?',
|
||
description: 'I\'ll generate a structured outline based on our conversation.',
|
||
button: 'Create Outline Now',
|
||
onClick: async () => {
|
||
setAgentMode('planning');
|
||
setInput('Create an outline based on our discussion');
|
||
// Small delay to ensure state updates, then trigger send
|
||
setTimeout(() => {
|
||
const sendButton = document.querySelector('.wpaw-send-btn');
|
||
if (sendButton) {
|
||
sendButton.click();
|
||
}
|
||
}, 100);
|
||
}
|
||
},
|
||
start_writing: {
|
||
icon: '✍️',
|
||
title: 'Ready to start writing?',
|
||
description: 'Let\'s turn your outline into a full article.',
|
||
button: 'Start Writing',
|
||
onClick: async () => {
|
||
setAgentMode('writing');
|
||
if (currentPlanRef.current) {
|
||
await executePlanFromCard();
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const action = actions[intent];
|
||
if (!action) return null;
|
||
|
||
return wp.element.createElement('div', { className: 'wpaw-contextual-action' },
|
||
wp.element.createElement('div', { className: 'wpaw-action-icon' }, action.icon),
|
||
wp.element.createElement('div', { className: 'wpaw-action-content' },
|
||
wp.element.createElement('h4', null, action.title),
|
||
wp.element.createElement('p', null, action.description),
|
||
wp.element.createElement(Button, {
|
||
isPrimary: true,
|
||
onClick: action.onClick
|
||
}, action.button)
|
||
)
|
||
);
|
||
};
|
||
|
||
// Render chat messages with timeline
|
||
const renderMessages = () => {
|
||
const normalizeMessageContent = (content) => {
|
||
if (content === null || content === undefined) {
|
||
return '';
|
||
}
|
||
if (typeof content === 'string' || typeof content === 'number') {
|
||
return String(content);
|
||
}
|
||
return JSON.stringify(content);
|
||
};
|
||
const escapeHtml = (value) => {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
};
|
||
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',
|
||
},
|
||
wp.element.createElement('div', { className: 'wpaw-plan-title' }, plan?.title || 'Proposed Outline'),
|
||
wp.element.createElement('div', { className: 'wpaw-plan-config-summary' },
|
||
configSummary.map((item, idx) => wp.element.createElement('div', { key: idx, className: 'wpaw-config-summary-item' }, item))
|
||
),
|
||
sections.length > 0 && wp.element.createElement('ol', { className: 'wpaw-plan-sections' },
|
||
sections.map((section, sectionIndex) => wp.element.createElement('li', {
|
||
key: `plan-section-${sectionIndex}`,
|
||
className: `wpaw-plan-section ${section.status || 'pending'}`,
|
||
},
|
||
wp.element.createElement('div', { className: 'wpaw-plan-section-row' },
|
||
wp.element.createElement('input', {
|
||
className: 'wpaw-plan-section-check',
|
||
type: 'checkbox',
|
||
checked: section.status === 'done',
|
||
readOnly: true,
|
||
disabled: true,
|
||
}),
|
||
wp.element.createElement('div', { className: 'wpaw-plan-section-body' },
|
||
wp.element.createElement('div', { className: 'wpaw-plan-section-title' }, section.title || section.heading || `Section ${sectionIndex + 1}`),
|
||
getSectionSummary(section) && wp.element.createElement('div', { className: 'wpaw-plan-section-desc' }, getSectionSummary(section))
|
||
),
|
||
wp.element.createElement('div', { className: 'wpaw-plan-section-status' }, section.status === 'done' ? 'Done' : section.status === 'in_progress' ? 'Writing' : 'Pending')
|
||
)
|
||
))
|
||
),
|
||
!sections.length && plan?.summary && wp.element.createElement('div', { className: 'wpaw-plan-section-desc' }, plan.summary),
|
||
wp.element.createElement('div', { className: 'wpaw-plan-actions' },
|
||
wp.element.createElement(Button, {
|
||
isPrimary: true,
|
||
onClick: executePlanFromCard,
|
||
disabled: isLoading,
|
||
}, buttonLabel)
|
||
)
|
||
);
|
||
}
|
||
|
||
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),
|
||
message.detectedIntent && renderContextualAction(message.detectedIntent),
|
||
message.showResumeActions && wp.element.createElement('div', { className: 'wpaw-resume-actions' },
|
||
wp.element.createElement(Button, {
|
||
isPrimary: true,
|
||
onClick: () => {
|
||
setExecutionStopped(false);
|
||
executePlanFromCard();
|
||
},
|
||
style: { marginRight: '8px' }
|
||
}, `Resume Writing (${message.pendingCount} pending)`),
|
||
wp.element.createElement(Button, {
|
||
isSecondary: true,
|
||
onClick: () => {
|
||
setExecutionStopped(false);
|
||
setAgentMode('planning');
|
||
}
|
||
}, 'Switch to Planning')
|
||
)
|
||
);
|
||
})
|
||
);
|
||
});
|
||
};
|
||
|
||
// 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)')
|
||
),
|
||
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.'
|
||
),
|
||
// Writing Mode Empty State
|
||
shouldShowWritingEmptyState() && renderWritingEmptyState(),
|
||
// Activity Log
|
||
!shouldShowWritingEmptyState() && 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 })
|
||
)
|
||
),
|
||
// Context Indicator (moved above textarea)
|
||
renderContextIndicator(),
|
||
// 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' + (isTextareaExpanded ? ' expanded' : '')
|
||
},
|
||
wp.element.createElement('span', { className: 'wpaw-command-prefix' }, '>'),
|
||
wp.element.createElement(TextareaControl, {
|
||
ref: inputRef,
|
||
value: input,
|
||
onChange: handleInputChange,
|
||
onKeyDown: handleKeyDown,
|
||
rows: isTextareaExpanded ? 20 : 3,
|
||
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'),
|
||
|
||
// Stop Button (appears during execution) - Circle with pause icon
|
||
isLoading && wp.element.createElement('button', {
|
||
className: 'wpaw-command-circle-btn wpaw-stop-circle-btn',
|
||
type: 'button',
|
||
onClick: handleStopExecution,
|
||
title: 'Stop execution',
|
||
dangerouslySetInnerHTML: {
|
||
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M11.945 5.75c-1.367 0-2.47 0-3.337.117c-.9.12-1.658.38-2.26.981c-.602.602-.86 1.36-.981 2.26c-.117.867-.117 1.97-.117 3.337v.11c0 1.367 0 2.47.117 3.337c.12.9.38 1.658.981 2.26c.602.602 1.36.86 2.26.982c.867.116 1.97.116 3.337.116h.11c1.367 0 2.47 0 3.337-.116c.9-.122 1.658-.38 2.26-.982s.86-1.36.982-2.26c.116-.867.116-1.97.116-3.337v-.11c0-1.367 0-2.47-.116-3.337c-.122-.9-.38-1.658-.982-2.26s-1.36-.86-2.26-.981c-.867-.117-1.97-.117-3.337-.117z"/></svg>'
|
||
}
|
||
}),
|
||
|
||
// Send Button (Bottom Right) - Circle with send icon
|
||
!isLoading && wp.element.createElement('button', {
|
||
className: 'wpaw-command-circle-btn wpaw-send-circle-btn',
|
||
type: 'button',
|
||
onClick: sendMessage,
|
||
disabled: !input.trim(),
|
||
title: 'Send message',
|
||
dangerouslySetInnerHTML: {
|
||
__html: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19.5 2.001a3.5 3.5 0 0 1 3.03 5.249l-7.5 12.99a3.5 3.5 0 0 1-6.411-.842l-1.5-5.595l8.77-5.064a1 1 0 0 0-1-1.732L6.12 12.07L2.026 7.975A3.5 3.5 0 0 1 4.5 2z"/></svg>'
|
||
}
|
||
})
|
||
)
|
||
)
|
||
)
|
||
)
|
||
);
|
||
};
|
||
|
||
// 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.element.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
|
||
wp.element.createElement('img', {
|
||
src: wpAgenticWriter.pluginUrl + '/assets/img/icon.svg',
|
||
alt: 'WP Agentic Writer',
|
||
style: { width: '24px', height: '24px' }
|
||
}),
|
||
wp.element.createElement('span', null, '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);
|
||
|
||
// Custom icon component using the SVG
|
||
const AgenticWriterIcon = () => wp.element.createElement('img', {
|
||
src: wpAgenticWriter.pluginUrl + '/assets/img/icon.svg',
|
||
alt: 'WP Agentic Writer',
|
||
style: { width: '20px', height: '20px' }
|
||
});
|
||
|
||
// Register plugin.
|
||
registerPlugin('wp-agentic-writer', {
|
||
icon: AgenticWriterIcon,
|
||
render: ConnectedSidebar,
|
||
});
|
||
})(window.wp);
|