/** * WP Agentic Writer - Gutenberg Sidebar * * @package WP_Agentic_Writer */ (function (wp) { const { registerPlugin } = wp.plugins; const { PluginSidebarMoreMenuItem } = wp.editPost; const { PluginSidebar } = wp.editPost; const { Panel, TextareaControl, TextControl, CheckboxControl, Button } = wp.components; const { dispatch, select } = wp.data; const { RawHTML } = wp.element; // Debug logger - only logs when SCRIPT_DEBUG is enabled const isDebug = typeof wpAgenticWriter !== "undefined" && wpAgenticWriter.debug; const wpawLog = { log: (...args) => { if (isDebug) console.log("[WPAW]", ...args); }, error: (...args) => console.error("[WPAW]", ...args), // Always log errors info: (...args) => { if (isDebug) console.info("[WPAW]", ...args); }, warn: (...args) => { if (isDebug) console.warn("[WPAW]", ...args); }, }; const pluginIcon = wp.element.createElement("img", { src: wpAgenticWriter.pluginUrl + "/assets/img/icon.svg", alt: "WP Agentic Writer", style: { width: "20px", height: "20px" }, }); // Sidebar Component. const AgenticWriterSidebar = ({ postId }) => { // Get settings from wpAgenticWriter global. const settings = typeof wpAgenticWriter !== "undefined" ? wpAgenticWriter.settings : {}; const formatAiErrorMessage = ( error, fallback = "The AI request failed.", ) => { const rawMessage = typeof error === "string" ? error : error?.message || fallback; const cleanMessage = String(rawMessage || fallback) .replace(/^API error:\s*/i, "") .trim(); const lowerMessage = cleanMessage.toLowerCase(); // Returns structured object { title, detail, actionUrl, actionLabel } const structured = (title, detail, actionUrl, actionLabel) => { return { title, detail, actionUrl: actionUrl || "", actionLabel: actionLabel || "", }; }; if ( lowerMessage.includes("no allowed providers are available") || (lowerMessage.includes("allowed providers") && lowerMessage.includes("selected model")) ) { const routedProvider = settings?.openrouter_provider_slug && settings.openrouter_provider_slug !== "auto" ? ` Pinned: ${settings.openrouter_provider_slug}.` : ""; return structured( "Model unavailable from current provider", `The pinned provider routing doesn't support this model.${routedProvider} Change provider routing or select a compatible model.`, settings?.settings_url || "", "Open Settings", ); } if (cleanMessage.includes("429") || lowerMessage.includes("rate limit")) { return structured( "Rate limit exceeded", "The AI provider is throttling requests. Wait a moment and try again.", ); } if ( cleanMessage.includes("cURL error 28") || lowerMessage.includes("operation timed out") || lowerMessage.includes("timed out after") ) { return structured( "Request timed out", "The model took too long to respond. Try a faster model, reduce content length, or check your provider routing.", settings?.settings_url || "", "Open Settings", ); } if ( cleanMessage.startsWith("HTTP 401") || lowerMessage.includes("unauthorized") ) { return structured( "API key rejected", "The provider rejected your API key. Check your key in settings.", settings?.settings_url || "", "Open Settings", ); } if ( cleanMessage.startsWith("HTTP 402") || lowerMessage.includes("insufficient credits") ) { return structured( "Insufficient credits", "Your provider account has no remaining credits or quota.", ); } if ( lowerMessage.includes("api key is not configured") || lowerMessage.includes("no_api_key") ) { return structured( "API key not configured", "Add your OpenRouter API key in plugin settings to start using AI features.", settings?.settings_url || "", "Configure API Key", ); } return structured(cleanMessage || fallback, ""); }; // Tab state (top tabs instead of bottom) 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 [currentSessionId, setCurrentSessionId] = React.useState(""); const [availableSessions, setAvailableSessions] = React.useState([]); const [isSessionActionLoading, setIsSessionActionLoading] = React.useState(false); const [agentMode, setAgentMode] = React.useState("chat"); // Session lock state (multi-tab safety) const tabIdRef = React.useRef( Math.random().toString(36).substring(2, 10) + Date.now().toString(36), ); const [sessionLock, setSessionLock] = React.useState({ locked: false, // true = we hold the lock (or no lock needed) lockedByOther: false, // true = another tab holds the lock holderTabId: "", }); const lockHeartbeatRef = React.useRef(null); // 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: "chat", // SEO fields seo_focus_keyword: "", 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); // Cost state const [cost, setCost] = React.useState({ session: 0, today: 0, monthlyUsed: 0, }); const [monthlyBudget, setMonthlyBudget] = React.useState( settings.monthly_budget || 600, ); // Provider info state for transparency display const [providerInfo, setProviderInfo] = React.useState(null); // Helper to extract and apply provider metadata from any AI response const applyProviderMetadata = (data) => { if (!data) return; if (data.session_id) { setCurrentSessionId(data.session_id); } // Support both nested provider_metadata and top-level provider fields const meta = data.provider_metadata || data; const provider = meta.provider || meta.selected_provider || meta.provider; if (provider) { setProviderInfo({ provider: provider, model: meta.model, fallbackUsed: meta.fallback_used || meta.fallbackUsed, warnings: meta.warnings || [], }); } }; const [isEditorLocked, setIsEditorLocked] = React.useState(false); const [isRefinementLocked, setIsRefinementLocked] = React.useState(false); const [refiningBlockIds, setRefiningBlockIds] = React.useState([]); const refinementDecoratedIdsRef = React.useRef([]); const lockedEditableNodesRef = React.useRef([]); const lockedBlockIdsRef = React.useRef([]); const REFINEMENT_ALL_CONFIRM_THRESHOLD = 25; const [refineAllConfirm, setRefineAllConfirm] = React.useState({ isOpen: false, blockCount: 0, dontAskAgain: false, }); const refineAllConfirmResolverRef = React.useRef(null); const skipRefineAllConfirmRef = React.useRef(false); // SEO audit state const [seoAudit, setSeoAudit] = React.useState(null); const [isSeoAuditing, setIsSeoAuditing] = React.useState(false); const [isGeneratingMeta, setIsGeneratingMeta] = React.useState(false); const [activeSeoFixKey, setActiveSeoFixKey] = React.useState(""); // 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("auto"); const [clarificationMode, setClarificationMode] = React.useState("generation"); const [pendingRefinement, setPendingRefinement] = React.useState(null); const [pendingEditPlan, setPendingEditPlan] = React.useState(null); const [pendingDiffBlockIds, setPendingDiffBlockIds] = React.useState([]); 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 activeAbortControllerRef = React.useRef(null); const activeReaderRef = React.useRef(null); const activeOperationRef = React.useRef({ type: "idle", status: "idle", label: "", }); const [executionStopped, setExecutionStopped] = React.useState(false); const [activeOperation, setActiveOperation] = React.useState({ type: "idle", status: "idle", label: "", }); const [writingState, setWritingState] = React.useState({ status: "idle", current_section_index: 0, sections_written: [], last_updated: null, plan_id: "", resume_token: "", }); const [isWritingStateLoading, setIsWritingStateLoading] = React.useState(false); const [workspaceSnapshot, setWorkspaceSnapshot] = React.useState({ title: "", blockCount: 0, selectedBlockLabel: "None selected", selectedBlockPreview: "", }); const [isWorkspaceCollapsed, setIsWorkspaceCollapsed] = React.useState( () => { try { return ( window.localStorage.getItem("wpaw_agent_workspace_collapsed") === "1" ); } catch (error) { return false; } }, ); const toggleAgentWorkspace = () => { setIsWorkspaceCollapsed((prev) => { const next = !prev; try { window.localStorage.setItem( "wpaw_agent_workspace_collapsed", next ? "1" : "0", ); } catch (error) { // Ignore storage failures; the in-session toggle still works. } return next; }); }; // 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); // Focus keyword state const [focusKeywordSuggestions, setFocusKeywordSuggestions] = React.useState([]); const [selectedFocusKeyword, setSelectedFocusKeyword] = React.useState(""); const [showCustomKeywordInput, setShowCustomKeywordInput] = React.useState(false); const [customKeywordInput, setCustomKeywordInput] = React.useState(""); const messagesSaveTimeoutRef = React.useRef(null); const lastPersistedMessagesRef = React.useRef(""); const isHydratingSessionRef = React.useRef(false); // Welcome screen state const [showWelcome, setShowWelcome] = React.useState(true); const [welcomeKeywordInput, setWelcomeKeywordInput] = React.useState(""); const [welcomeStartMode, setWelcomeStartMode] = React.useState("chat"); // 'chat' or 'planning' // Undo stack for AI operations const [aiUndoStack, setAiUndoStack] = React.useState([]); const MAX_UNDO_STACK = 10; // MEMANTO memory restore state const [memantoRestore, setMemantoRestore] = React.useState({ restored: false, summary: "", memories: [], preferences: [], systemMessage: "", }); const memantoRestoreFetchedRef = React.useRef(false); React.useEffect(() => { if (agentMode === "writing" && !isLoading) { setAgentMode("chat"); } }, [agentMode, isLoading]); React.useEffect(() => { if (!postId) { return; } 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 }; merged.default_mode = "chat"; setPostConfig(merged); lastSavedConfigRef.current = JSON.stringify(merged); configHydratedRef.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]); const normalizeWritingState = (state = {}) => ({ status: state.status || "idle", current_section_index: Number(state.current_section_index || 0), sections_written: Array.isArray(state.sections_written) ? state.sections_written : [], last_updated: state.last_updated || null, plan_id: state.plan_id || "", resume_token: state.resume_token || "", }); const saveWritingState = React.useCallback( async (statePatch) => { if (!postId) { return; } const nextState = normalizeWritingState(statePatch); try { const response = await fetch( `${wpAgenticWriter.apiUrl}/writing-state/${postId}`, { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify(nextState), }, ); if (!response.ok) { throw new Error("Failed to save writing state"); } } catch (error) { wpawLog.warn("Writing state save failed:", error); } }, [postId], ); const persistWritingStatePatch = React.useCallback( (patch) => { setWritingState((prev) => { const next = normalizeWritingState({ ...prev, ...patch }); saveWritingState(next); return next; }); }, [saveWritingState], ); React.useEffect(() => { if (!postId) { return; } let cancelled = false; setIsWritingStateLoading(true); fetch(`${wpAgenticWriter.apiUrl}/writing-state/${postId}`, { headers: { "X-WP-Nonce": wpAgenticWriter.nonce, }, }) .then((response) => response.ok ? response.json() : Promise.reject(response), ) .then((data) => { if (!cancelled) { setWritingState(normalizeWritingState(data)); } }) .catch((error) => { wpawLog.warn("Writing state load failed:", error); }) .finally(() => { if (!cancelled) { setIsWritingStateLoading(false); } }); return () => { cancelled = true; }; }, [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; }); }; const addActivityTimeline = (status, message, options = {}) => { const { deactivate = true, extra = {} } = options; setMessages((prev) => [ ...(deactivate ? deactivateActiveTimelineEntries(prev) : prev), { role: "system", type: "timeline", status, message, timestamp: new Date(), ...extra, }, ]); }; const setActiveOperationState = (nextOperation) => { const normalized = { type: nextOperation?.type || "idle", status: nextOperation?.status || "idle", label: nextOperation?.label || "", }; activeOperationRef.current = normalized; setActiveOperation(normalized); }; const beginAgentOperation = (type, label) => { stopExecutionRef.current = false; const controller = new AbortController(); activeAbortControllerRef.current = controller; activeReaderRef.current = null; setExecutionStopped(false); setActiveOperationState({ type, status: "running", label }); return controller; }; const finishAgentOperation = (type = "") => { const current = activeOperationRef.current || {}; if (type && current.type && current.type !== type) { return; } activeAbortControllerRef.current = null; activeReaderRef.current = null; setActiveOperationState({ type: "idle", status: "idle", label: "" }); }; const markActiveOperationStopping = () => { const current = activeOperationRef.current || {}; setActiveOperationState({ type: current.type || "unknown", status: "stopping", label: current.label || "operation", }); setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "stopping", message: `Stopping ${current.label || "operation"}...`, timestamp: new Date(), }; } return newMessages; }); }; const isAbortError = (error) => error?.name === "AbortError" || /aborted|abort/i.test(String(error?.message || "")); const registerActiveReader = (reader) => { activeReaderRef.current = reader; return reader; }; const requestRefineAllConfirmation = React.useCallback((blockCount) => { if (skipRefineAllConfirmRef.current) { return Promise.resolve(true); } return new Promise((resolve) => { refineAllConfirmResolverRef.current = resolve; setRefineAllConfirm({ isOpen: true, blockCount: Number(blockCount) || 0, dontAskAgain: false, }); }); }, []); const resolveRefineAllConfirmation = React.useCallback((approved) => { const resolver = refineAllConfirmResolverRef.current; refineAllConfirmResolverRef.current = null; setRefineAllConfirm((prev) => ({ ...prev, isOpen: false })); if (resolver) { resolver(Boolean(approved)); } }, []); // 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) { wpawLog.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]); React.useEffect(() => { if (isRefinementLocked) { dispatch("core/editor").lockPostSaving("wpaw-refining"); document.body.classList.add("wpaw-refining-locked"); } else { dispatch("core/editor").unlockPostSaving("wpaw-refining"); document.body.classList.remove("wpaw-refining-locked"); } }, [isRefinementLocked]); React.useEffect(() => { const blockEditorDispatch = dispatch("core/block-editor"); if ( !blockEditorDispatch || typeof blockEditorDispatch.setBlockEditingMode !== "function" ) { return undefined; } if (isRefinementLocked) { const allBlocks = select("core/block-editor").getBlocks(); const ids = []; const collectIds = (blocks) => { blocks.forEach((block) => { if (!block?.clientId) { return; } ids.push(block.clientId); if ( Array.isArray(block.innerBlocks) && block.innerBlocks.length > 0 ) { collectIds(block.innerBlocks); } }); }; collectIds(allBlocks); lockedBlockIdsRef.current = ids; ids.forEach((id) => blockEditorDispatch.setBlockEditingMode(id, "disabled"), ); } else if (lockedBlockIdsRef.current.length > 0) { lockedBlockIdsRef.current.forEach((id) => blockEditorDispatch.setBlockEditingMode(id, "default"), ); lockedBlockIdsRef.current = []; } return () => { if (lockedBlockIdsRef.current.length > 0) { lockedBlockIdsRef.current.forEach((id) => blockEditorDispatch.setBlockEditingMode(id, "default"), ); lockedBlockIdsRef.current = []; } }; }, [isRefinementLocked, messages]); React.useEffect(() => { const prevIds = refinementDecoratedIdsRef.current || []; prevIds.forEach((id) => { const node = document.querySelector(`[data-block="${id}"]`); if (node) { node.classList.remove("wpaw-block-refining"); } }); if (isRefinementLocked && Array.isArray(refiningBlockIds)) { refiningBlockIds.forEach((id) => { const node = document.querySelector(`[data-block="${id}"]`); if (node) { node.classList.add("wpaw-block-refining"); } }); refinementDecoratedIdsRef.current = [...refiningBlockIds]; } else { refinementDecoratedIdsRef.current = []; } return () => { const cleanupIds = refinementDecoratedIdsRef.current || []; cleanupIds.forEach((id) => { const node = document.querySelector(`[data-block="${id}"]`); if (node) { node.classList.remove("wpaw-block-refining"); } }); }; }, [isRefinementLocked, refiningBlockIds, messages]); React.useEffect(() => { if (!isRefinementLocked) { return undefined; } const shouldBlockEditorInput = (eventTarget) => { if (!eventTarget || !(eventTarget instanceof Element)) { return false; } if ( eventTarget.closest( ".wpaw-sidebar, .wpaw-command-area, .wpaw-messages", ) ) { return false; } return Boolean( eventTarget.closest( ".interface-interface-skeleton__content, .editor-styles-wrapper, .block-editor-writing-flow", ), ); }; const keydownHandler = (event) => { if (!shouldBlockEditorInput(event.target)) { return; } if (event.metaKey || event.ctrlKey || event.altKey) { return; } const blockedKeys = new Set(["Enter", "Backspace", "Delete", "Tab"]); if ( (typeof event.key === "string" && event.key.length === 1) || blockedKeys.has(event.key) ) { event.preventDefault(); event.stopPropagation(); } }; const blockMutationEvent = (event) => { if (!shouldBlockEditorInput(event.target)) { return; } event.preventDefault(); event.stopPropagation(); }; document.addEventListener("keydown", keydownHandler, true); document.addEventListener("paste", blockMutationEvent, true); document.addEventListener("drop", blockMutationEvent, true); document.addEventListener("cut", blockMutationEvent, true); return () => { document.removeEventListener("keydown", keydownHandler, true); document.removeEventListener("paste", blockMutationEvent, true); document.removeEventListener("drop", blockMutationEvent, true); document.removeEventListener("cut", blockMutationEvent, true); }; }, [isRefinementLocked]); React.useEffect(() => { if (isRefinementLocked) { const editableNodes = Array.from( document.querySelectorAll( '.editor-styles-wrapper [contenteditable="true"]', ), ); lockedEditableNodesRef.current = editableNodes.map((node) => ({ node, prev: node.getAttribute("contenteditable"), })); lockedEditableNodesRef.current.forEach(({ node }) => { node.setAttribute("contenteditable", "false"); }); } else { (lockedEditableNodesRef.current || []).forEach(({ node, prev }) => { if (!node) return; if (prev === null) { node.removeAttribute("contenteditable"); } else { node.setAttribute("contenteditable", prev); } }); lockedEditableNodesRef.current = []; } return () => { (lockedEditableNodesRef.current || []).forEach(({ node, prev }) => { if (!node) return; if (prev === null) { node.removeAttribute("contenteditable"); } else { node.setAttribute("contenteditable", prev); } }); lockedEditableNodesRef.current = []; }; }, [isRefinementLocked, messages]); 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 })); }; const buildPostConfigFromAnswers = (answerMap = {}) => { const merged = { ...postConfig }; if (answerMap.config_language) { let languageValue = answerMap.config_language; if ( languageValue === "__custom__" && answerMap.config_language_custom ) { languageValue = answerMap.config_language_custom.toLowerCase().trim(); } if (languageValue && languageValue !== "__skipped__") { merged.language = languageValue; } } if (answerMap.config_all) { try { const configData = JSON.parse(answerMap.config_all); if (configData.web_search !== undefined) { merged.web_search = configData.web_search; } if (configData.seo !== undefined) { merged.seo_enabled = configData.seo; } if (configData.focus_keyword) { merged.focus_keyword = configData.focus_keyword; merged.seo_focus_keyword = configData.focus_keyword; } if (configData.secondary_keywords) { merged.seo_secondary_keywords = configData.secondary_keywords; } } catch (error) { wpawLog.error("Failed to merge config answers:", error); } } return merged; }; // Focus keyword handlers const handleFocusKeywordChange = (keyword) => { setSelectedFocusKeyword(keyword); updatePostConfig("focus_keyword", keyword); updatePostConfig("seo_focus_keyword", keyword); setShowCustomKeywordInput(false); setCustomKeywordInput(""); }; const handleKeywordSelect = (e) => { const value = e.target.value; if (value === "__custom__") { setShowCustomKeywordInput(true); } else { handleFocusKeywordChange(value); } }; // Extract ALL focus keyword suggestions from AI response (returns array) const extractFocusKeywordSuggestions = (aiResponse) => { if (!aiResponse || typeof aiResponse !== "string") return []; const suggestions = []; // Method 1: Bullet list after "Fokus Keyword Suggestion:" or "Focus Keyword Suggestion:" // Matches: - "Keyword Here" or * "Keyword Here" or - Keyword Here const bulletListMatch = aiResponse.match( /(?:fokus|focus)\s+keyword\s+suggestion[s]?\s*:\s*([\s\S]*?)(?=\n\n|Pilih|$)/i, ); if (bulletListMatch) { const listContent = bulletListMatch[1]; // Extract items from bullet list (- or *) const bulletItems = listContent.match(/[-*]\s*["']?([^"'\n]+)["']?/g); if (bulletItems) { bulletItems.forEach((item) => { const cleaned = item .replace(/^[-*]\s*["']?/, "") .replace(/["']?$/, "") .trim(); if (cleaned.length > 2 && cleaned.length < 60) { suggestions.push(cleaned); } }); } } // Method 2: Single line "Focus Keyword Suggestion: keyword" if (suggestions.length === 0) { const singleMatch = aiResponse.match( /(?:fokus|focus)\s+keyword\s+suggestion[s]?\s*:\s*["']?([^"'\n]+)["']?/i, ); if ( singleMatch && !singleMatch[1].includes("-") && !singleMatch[1].includes("*") ) { const kw = singleMatch[1].trim(); if (kw.length > 2 && kw.length < 60) { suggestions.push(kw); } } } return suggestions; }; // Legacy single extraction (for backward compatibility) const extractFocusKeywordSuggestion = (aiResponse) => { const suggestions = extractFocusKeywordSuggestions(aiResponse); return suggestions.length > 0 ? suggestions[0] : null; }; const addFocusKeywordSuggestion = (suggestion) => { if (!suggestion) return; setFocusKeywordSuggestions((prev) => { if (prev.includes(suggestion)) return prev; const updated = [...prev, suggestion]; return updated.slice(-5); // Keep max 5 suggestions }); // Don't auto-select - let user choose }; // Add multiple suggestions at once const addFocusKeywordSuggestions = (suggestions) => { if (!suggestions || !Array.isArray(suggestions)) return; suggestions.forEach((s) => addFocusKeywordSuggestion(s)); }; // Load focus keyword from postConfig on mount React.useEffect(() => { if (postConfig.focus_keyword && !selectedFocusKeyword) { setSelectedFocusKeyword(postConfig.focus_keyword); } else if (postConfig.seo_focus_keyword && !selectedFocusKeyword) { setSelectedFocusKeyword(postConfig.seo_focus_keyword); } }, [postConfig.focus_keyword, postConfig.seo_focus_keyword]); // Check if should show welcome screen (no messages yet) React.useEffect(() => { if (messages.length > 0 || currentPlanRef.current) { setShowWelcome(false); } }, [messages.length]); // Welcome screen start handler const handleWelcomeStart = () => { // Set focus keyword if provided (but don't add to AI suggestions - it's user input) if (welcomeKeywordInput.trim()) { const keyword = welcomeKeywordInput.trim(); handleFocusKeywordChange(keyword); // NOT adding to suggestions - user input is NOT AI suggestion } // Set mode and hide welcome setAgentMode(welcomeStartMode); setShowWelcome(false); // Focus the input setTimeout(() => { if (inputRef.current) { inputRef.current.focus(); } }, 100); }; // Run SEO Audit const runSeoAudit = async () => { if (isSeoAuditing || !postId) return; const operationController = beginAgentOperation("seo_audit", "SEO audit"); setIsSeoAuditing(true); try { const response = await fetch( `${wpAgenticWriter.apiUrl}/seo-audit/${postId}`, { headers: { "X-WP-Nonce": wpAgenticWriter.nonce, }, signal: operationController.signal, }, ); const data = await response.json(); if (!response.ok) { throw new Error(data.message || "Failed to run SEO audit"); } setSeoAudit(data); setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "complete", message: "SEO audit complete.", completedAt: new Date(), }; } return newMessages; }); } catch (error) { if (isAbortError(error)) { setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "stopped", message: "SEO audit stopped.", }; } return newMessages; }); return; } wpawLog.error("SEO Audit error:", error); setMessages((prev) => [ ...prev, { role: "assistant", content: `SEO Audit error: ${error.message}`, type: "error", }, ]); setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "error", message: "SEO audit failed.", }; } return newMessages; }); } finally { setIsSeoAuditing(false); finishAgentOperation("seo_audit"); } }; const buildSeoAuditFixInstruction = (check) => { const focusKeyword = selectedFocusKeyword || postConfig.focus_keyword || postConfig.seo_focus_keyword || ""; const languageHint = postConfig.language && postConfig.language !== "auto" ? `Use ${postConfig.language}.` : "Use the same language as the article."; const issueName = check?.name || "SEO audit issue"; const issueMessage = check?.message || ""; const focusHint = focusKeyword ? `Focus keyword: "${focusKeyword}".` : "If a focus keyword is missing, ask the user to set one first."; const lowerName = String(issueName).toLowerCase(); if (lowerName.includes("keyword in intro")) { return `Fix this SEO audit issue in the article introduction: ${issueMessage}. ${focusHint} Add the focus keyword naturally in the first paragraph without sounding forced. ${languageHint}`; } if (lowerName.includes("keyword density")) { return `Fix this SEO audit issue across the article: ${issueMessage}. ${focusHint} Improve keyword usage naturally, avoid stuffing, and keep the writing human and useful. ${languageHint}`; } if (lowerName.includes("ai-ish")) { return `Fix this audit issue across the article: ${issueMessage}. Make the writing more natural, specific, and human. Reduce generic AI-style phrasing while preserving meaning, structure, and facts. ${languageHint}`; } if (lowerName.includes("content length")) { return `Fix this SEO audit issue: ${issueMessage}. Expand the article with useful, non-fluffy details, examples, and reader guidance. ${focusHint} ${languageHint}`; } if (lowerName.includes("subheadings")) { return `Fix this readability issue: ${issueMessage}. Improve the article structure with useful H2/H3 subheadings while preserving the article's intent. ${focusHint} ${languageHint}`; } return `Fix this SEO audit issue: ${issueName}: ${issueMessage}. ${focusHint} Keep the result natural, useful, and aligned with the article intent. ${languageHint}`; }; const getSeoFixKey = (check) => `${check?.name || ""}:${check?.message || ""}`; const getSeoAuditPatternCount = (check) => { const auditCount = Number(seoAudit?.ai_ish_pattern_count || 0); if (auditCount > 0) { return auditCount; } const match = String(check?.message || "").match(/(\d+)\s+pattern/i); return match ? Number(match[1]) : 0; }; const formatCountLabel = (count, singular, plural = `${singular}s`) => { const numeric = Number(count || 0); return `${numeric} ${numeric === 1 ? singular : plural}`; }; const formatAuditPatternLabel = (auditContext) => { const patternCount = Number(auditContext?.patternCount || 0); return patternCount > 0 ? formatCountLabel(patternCount, "pattern occurrence") : "audit pattern occurrences"; }; const buildAuditRefinementContext = ( check, targetBlocks, refineableBlocks, ) => { const issueName = check?.name || "SEO audit issue"; return { source: "seo_audit", issueName, auditMessage: check?.message || "", patternCount: getSeoAuditPatternCount(check), candidateBlockCount: Array.isArray(targetBlocks) ? targetBlocks.length : 0, refineableBlockCount: Array.isArray(refineableBlocks) ? refineableBlocks.length : 0, }; }; const handleSeoAuditFix = async (check) => { if (isLoading || isSeoAuditing || !check) { return; } const issueName = String(check.name || "").toLowerCase(); const focusKeyword = selectedFocusKeyword || postConfig.focus_keyword || postConfig.seo_focus_keyword || ""; const fixKey = getSeoFixKey(check); setActiveSeoFixKey(fixKey); if (issueName.includes("focus keyword") && !focusKeyword) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Set a focus keyword first, then run the audit again.", }, ]); setActiveTab("config"); setActiveSeoFixKey(""); return; } setActiveTab("chat"); setShowWelcome(false); try { if (issueName.includes("meta description")) { setMessages((prev) => [ ...prev, { role: "user", content: `Fix SEO audit: ${check.message}` }, ]); setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "refining", message: "Generating SEO meta description...", timestamp: new Date(), }, ]); await generateMetaDescription(); return; } if (issueName.includes("keyword in title")) { const titleInstruction = focusKeyword ? `include the focus keyword "${focusKeyword}" naturally in the title, keep it compelling, and match the article language` : "make the title more SEO-friendly and aligned with the article"; await handleTitleRefinement(`@title ${titleInstruction}`, ["@title"]); return; } const refineableBlocks = getRefineableBlocks(); if (!refineableBlocks.length) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "No article blocks found to fix yet. Generate or insert content first, then run the audit again.", }, ]); return; } const instruction = buildSeoAuditFixInstruction(check); const targetBlocks = issueName.includes("ai-ish") ? selectLikelyAiSlopBlocks(instruction, refineableBlocks) : refineableBlocks; if (issueName.includes("ai-ish") && targetBlocks.length === 0) { const patternCount = getSeoAuditPatternCount(check); setMessages((prev) => [ ...prev, { role: "assistant", content: patternCount > 0 ? `Audit found ${formatCountLabel(patternCount, "pattern occurrence")}, but I could not safely map those occurrences to editor blocks. I did not send the whole article to refinement.` : "I rechecked the editor blocks and did not find AI-ish pattern matches, so I did not send the whole article to refinement.", }, ]); return; } await handleChatRefinement( instruction, targetBlocks.map((block) => block.clientId), { useDiffPlan: false, auditContext: issueName.includes("ai-ish") ? buildAuditRefinementContext( check, targetBlocks, refineableBlocks, ) : null, }, ); } finally { setActiveSeoFixKey(""); } }; const generateMetaDescription = async () => { if (isGeneratingMeta) return; const operationController = beginAgentOperation( "meta", "meta description", ); 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, sessionId: currentSessionId, focusKeyword: postConfig.seo_focus_keyword, chatHistory: messages.filter((m) => m.role !== "system"), }), signal: operationController.signal, }, ); if (!response.ok) { const data = await response.json(); throw new Error( data.message || "Failed to generate meta description", ); } const data = await response.json(); applyProviderMetadata(data); if (data.meta_description) { updatePostConfig("seo_meta_description", data.meta_description); setMessages((prev) => [ ...prev, { role: "assistant", content: `✅ Meta description generated successfully`, type: "success", }, ]); setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "complete", message: "Meta description generated.", completedAt: new Date(), }; } return newMessages; }); } else { throw new Error("No meta description returned from API"); } } catch (error) { if (isAbortError(error)) { setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "stopped", message: "Meta description generation stopped.", }; } return newMessages; }); return; } wpawLog.error("Error generating meta description:", error); setMessages((prev) => [ ...prev, { role: "system", content: `❌ Failed to generate meta description: ${error.message}`, type: "error", }, ]); setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "error", message: "Meta description failed.", }; } return newMessages; }); } finally { setIsGeneratingMeta(false); finishAgentOperation("meta"); } }; 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); }; const buildWorkspaceSnapshot = React.useCallback(() => { const editor = select("core/editor"); const blockEditor = select("core/block-editor"); const allBlocks = blockEditor?.getBlocks ? blockEditor.getBlocks() : []; const selectedBlockId = blockEditor?.getSelectedBlockClientId ? blockEditor.getSelectedBlockClientId() : ""; const selectedBlock = selectedBlockId && blockEditor?.getBlock ? blockEditor.getBlock(selectedBlockId) : null; const postTitle = editor?.getEditedPostAttribute ? editor.getEditedPostAttribute("title") || "" : ""; const selectedType = selectedBlock?.name ? selectedBlock.name.replace("core/", "") : ""; const selectedPreview = selectedBlock ? extractBlockPreview(selectedBlock) : ""; return { title: postTitle || "Untitled draft", blockCount: allBlocks.filter((block) => { const preview = extractBlockPreview(block); return ( preview || (Array.isArray(block.innerBlocks) && block.innerBlocks.length > 0) ); }).length, selectedBlockLabel: selectedBlock ? `${selectedType || "block"} ${selectedBlockId.slice(0, 6)}` : "None selected", selectedBlockPreview: selectedPreview ? selectedPreview.slice(0, 90) : "", }; }, []); React.useEffect(() => { const updateSnapshot = () => { const next = buildWorkspaceSnapshot(); setWorkspaceSnapshot((prev) => { if (JSON.stringify(prev) === JSON.stringify(next)) { return prev; } return next; }); }; updateSnapshot(); const unsubscribe = wp.data?.subscribe ? wp.data.subscribe(updateSnapshot) : null; return () => { if (typeof unsubscribe === "function") { unsubscribe(); } }; }, [buildWorkspaceSnapshot]); // 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(() => { const handleBeforeUnload = (event) => { if (!isLoading) { return; } event.preventDefault(); event.returnValue = ""; return ""; }; window.addEventListener("beforeunload", handleBeforeUnload); return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, [isLoading]); React.useEffect(() => { loadSectionBlocks(); }, [postId]); React.useEffect(() => { if (!postId) { return; } try { const savedSession = window.localStorage.getItem( `wpawSessionId_${postId}`, ); if (savedSession) { setCurrentSessionId(savedSession); } } catch (error) { // Ignore storage read errors. } }, [postId]); React.useEffect(() => { if (!postId || !currentSessionId) { return; } try { window.localStorage.setItem( `wpawSessionId_${postId}`, currentSessionId, ); } catch (error) { // Ignore storage write errors. } }, [postId, currentSessionId]); const sanitizeMessagesForStorage = React.useCallback((items) => { if (!Array.isArray(items)) { return []; } const MAX_MESSAGES = 300; const clipped = items.slice(-MAX_MESSAGES); return clipped.map((msg) => { const out = {}; out.role = typeof msg?.role === "string" ? msg.role : "assistant"; if (typeof msg?.content === "string") { out.content = msg.content; } if (typeof msg?.type === "string") { out.type = msg.type; } if (typeof msg?.status === "string") { out.status = msg.status; } if (msg?.timestamp) { out.timestamp = msg.timestamp; } if (Array.isArray(msg?.sections)) { out.sections = msg.sections; } if (msg?.meta && typeof msg.meta === "object") { out.meta = msg.meta; } if (msg?.plan && typeof msg.plan === "object") { out.plan = msg.plan; } return out; }); }, []); const hydrateSessionStateFromMessages = React.useCallback( (sessionMessages) => { if (!Array.isArray(sessionMessages) || sessionMessages.length === 0) { currentPlanRef.current = null; setAgentMode("chat"); return; } let latestPlan = null; for (let i = sessionMessages.length - 1; i >= 0; i -= 1) { if (sessionMessages[i]?.type === "plan" && sessionMessages[i]?.plan) { latestPlan = ensurePlanTasks(sessionMessages[i].plan); break; } } currentPlanRef.current = latestPlan; if (latestPlan) { setAgentMode("planning"); } else { setAgentMode("chat"); } setShowWelcome(false); }, [], ); const persistSessionMessages = React.useCallback( async ( forceSessionId = null, skipSanitization = false, targetMessages = null, ) => { const sessionId = forceSessionId || currentSessionId; if (!sessionId) { return; } const msgsToSave = targetMessages || (messagesRef && messagesRef.current ? messagesRef.current : messages); // Safety: never overwrite the DB with an empty array. This prevents // race conditions during session switching from wiping message history. if (!Array.isArray(msgsToSave) || msgsToSave.length === 0) { return; } const sanitized = skipSanitization ? msgsToSave : sanitizeMessagesForStorage(msgsToSave); const serialized = JSON.stringify(sanitized); if (serialized === lastPersistedMessagesRef.current) { return; } try { const response = await fetch( `${wpAgenticWriter.apiUrl}/conversations/${sessionId}/messages`, { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ messages: sanitized }), }, ); if (!response.ok) { throw new Error("Failed to persist session messages"); } lastPersistedMessagesRef.current = serialized; } catch (error) { // Non-fatal: keep editor responsive, but do not mark this state persisted. window.console?.warn?.( "WP Agentic Writer: failed to persist session messages.", error, ); } }, [sanitizeMessagesForStorage, currentSessionId], ); // Stable ref for the latest messages array to prevent stale closures in unload events const messagesRef = React.useRef(messages); React.useEffect(() => { messagesRef.current = messages; }, [messages]); // Flush pending message persistence on page unload to prevent data loss React.useEffect(() => { const flushOnUnload = () => { if (messagesSaveTimeoutRef.current) { clearTimeout(messagesSaveTimeoutRef.current); } if (!currentSessionId || isHydratingSessionRef.current) { return; } // Use messagesRef.current (or fallback to messages) to prevent stale closures const currentMessages = messagesRef && messagesRef.current ? messagesRef.current : messages; // Never flush empty messages — prevents wiping DB on session switch / race if (!Array.isArray(currentMessages) || currentMessages.length === 0) { return; } const sanitized = sanitizeMessagesForStorage(currentMessages); const serialized = JSON.stringify(sanitized); if (serialized === lastPersistedMessagesRef.current) { return; } // Synchronous XHR as last resort during unload (sendBeacon can't set headers) try { const xhr = new XMLHttpRequest(); xhr.open( "POST", `${wpAgenticWriter.apiUrl}/conversations/${currentSessionId}/messages`, false, ); // sync xhr.setRequestHeader("Content-Type", "application/json"); xhr.setRequestHeader("X-WP-Nonce", wpAgenticWriter.nonce); xhr.send(JSON.stringify({ messages: sanitized })); } catch (error) { // Ignore unload errors } }; window.addEventListener("beforeunload", flushOnUnload); return () => window.removeEventListener("beforeunload", flushOnUnload); }, [currentSessionId, sanitizeMessagesForStorage]); // ── Session edit-lock heartbeat ────────────────────────────────────── const acquireSessionLock = React.useCallback(async (sessionId) => { if (!sessionId) return false; try { const res = await fetch( `${wpAgenticWriter.apiUrl}/conversations/${sessionId}/lock`, { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ tab_id: tabIdRef.current }), }, ); if (!res.ok) return false; const data = await res.json(); if (data.locked) { setSessionLock({ locked: true, lockedByOther: false, holderTabId: "", }); return true; } // Another tab holds the lock setSessionLock({ locked: false, lockedByOther: true, holderTabId: data.holder?.tab_id || "", }); return false; } catch (e) { // Network error — assume unlocked to avoid blocking the user return true; } }, []); const releaseSessionLock = React.useCallback(async (sessionId) => { if (!sessionId) return; try { // Use sendBeacon-style sync for reliability on unload await fetch( `${wpAgenticWriter.apiUrl}/conversations/${sessionId}/lock`, { method: "DELETE", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ tab_id: tabIdRef.current }), keepalive: true, }, ); } catch (e) { // Best-effort } }, []); const startLockHeartbeat = React.useCallback( (sessionId) => { // Clear any existing heartbeat if (lockHeartbeatRef.current) { clearInterval(lockHeartbeatRef.current); } // Heartbeat: refresh lock every 30 seconds lockHeartbeatRef.current = setInterval(() => { acquireSessionLock(sessionId); }, 30000); }, [acquireSessionLock], ); const stopLockHeartbeat = React.useCallback(() => { if (lockHeartbeatRef.current) { clearInterval(lockHeartbeatRef.current); lockHeartbeatRef.current = null; } }, []); // Release lock + stop heartbeat on unload React.useEffect(() => { const releaseLockOnUnload = () => { if (!currentSessionId) return; stopLockHeartbeat(); // Sync XHR for unload reliability try { const xhr = new XMLHttpRequest(); xhr.open( "DELETE", `${wpAgenticWriter.apiUrl}/conversations/${currentSessionId}/lock`, false, ); xhr.setRequestHeader("Content-Type", "application/json"); xhr.setRequestHeader("X-WP-Nonce", wpAgenticWriter.nonce); xhr.send(JSON.stringify({ tab_id: tabIdRef.current })); } catch (e) { // Best-effort } }; window.addEventListener("beforeunload", releaseLockOnUnload); return () => window.removeEventListener("beforeunload", releaseLockOnUnload); }, [currentSessionId, stopLockHeartbeat]); // Force-take-over: acquire the lock regardless of who holds it const takeOverSession = React.useCallback(async () => { if (!currentSessionId) return; // Force-acquire by telling the server to ignore the expiry window try { const res = await fetch( `${wpAgenticWriter.apiUrl}/conversations/${currentSessionId}/lock`, { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ tab_id: tabIdRef.current, force: true }), }, ); if (res.ok) { const data = await res.json(); if (data.locked) { setSessionLock({ locked: true, lockedByOther: false, holderTabId: "", }); startLockHeartbeat(currentSessionId); } } } catch (e) { // Best-effort } }, [currentSessionId, startLockHeartbeat]); // Auto-save debounced messages to database React.useEffect(() => { if (!currentSessionId) { return; } if (isHydratingSessionRef.current) { return; } // Don't auto-save if another tab holds the edit lock if (sessionLock.lockedByOther) { return; } // Never auto-persist an empty messages array — this protects against // race conditions where setMessages([]) fires during session switches // and the debounce timer would overwrite the DB with nothing. const currentMsgs = messagesRef && messagesRef.current ? messagesRef.current : messages; if (!Array.isArray(currentMsgs) || currentMsgs.length === 0) { return; } if (messagesSaveTimeoutRef.current) { clearTimeout(messagesSaveTimeoutRef.current); } messagesSaveTimeoutRef.current = setTimeout(() => { // Re-check at fire time: skip if hydrating or messages are now empty if (isHydratingSessionRef.current) { return; } const latestMsgs = messagesRef && messagesRef.current ? messagesRef.current : []; if (latestMsgs.length === 0) { return; } persistSessionMessages(currentSessionId, false, latestMsgs); }, 3000); return () => { if (messagesSaveTimeoutRef.current) { clearTimeout(messagesSaveTimeoutRef.current); } }; }, [currentSessionId, messages, persistSessionMessages]); React.useEffect(() => { const loadChatHistory = async () => { // Skip if we already have a session loaded (e.g., from openSessionById) if (messages.length > 0 || isHydratingSessionRef.current) { return; } try { const headers = { "X-WP-Nonce": wpAgenticWriter.nonce, }; let historyMessages = []; let resolvedSessionId = ""; // Primary source: merged sessions list (post sessions + unassigned sessions). const sessions = await loadPostSessions(); if (sessions.length > 0) { if (sessions.length > 0) { let selected = sessions[0]; const preferred = (() => { try { return ( window.localStorage.getItem(`wpawSessionId_${postId}`) || "" ); } catch (error) { return ""; } })(); if (preferred) { const match = sessions.find((s) => s?.session_id === preferred); if (match) { selected = match; } } resolvedSessionId = selected?.session_id || ""; if ( Array.isArray(selected?.messages) && selected.messages.length > 0 ) { historyMessages = selected.messages; } } } // If we found a session but its messages weren't inlined in the list // response (e.g. from the user-sessions endpoint which only returns // message_count), fetch the full session to get actual messages. if (resolvedSessionId && historyMessages.length === 0) { try { const fullSession = await fetch( `${wpAgenticWriter.apiUrl}/conversations/${resolvedSessionId}`, { method: "GET", headers }, ); if (fullSession.ok) { const fullData = await fullSession.json(); if ( fullData && Array.isArray(fullData.messages) && fullData.messages.length > 0 ) { historyMessages = fullData.messages; } } } catch (e) { // Non-fatal: fall through to legacy endpoints } } // Canonical single-session endpoint fallback. if (postId && !resolvedSessionId) { const primary = await fetch( `${wpAgenticWriter.apiUrl}/conversation/${postId}`, { method: "GET", headers, }, ); if (primary.ok) { const data = await primary.json(); if (data?.session_id) { resolvedSessionId = data.session_id; } if ( data && Array.isArray(data.messages) && data.messages.length > 0 ) { historyMessages = data.messages; } } } // Legacy endpoint fallback - only if no session found at all. if (postId && historyMessages.length === 0 && !resolvedSessionId) { const legacy = await fetch( `${wpAgenticWriter.apiUrl}/chat-history/${postId}`, { method: "GET", headers, }, ); if (legacy.ok) { const legacyData = await legacy.json(); if ( legacyData && Array.isArray(legacyData.messages) && legacyData.messages.length > 0 ) { historyMessages = legacyData.messages; } } } if (historyMessages.length > 0) { isHydratingSessionRef.current = true; lastPersistedMessagesRef.current = JSON.stringify( sanitizeMessagesForStorage(historyMessages), ); hydrateSessionStateFromMessages(historyMessages); setMessages(historyMessages); setTimeout(() => { isHydratingSessionRef.current = false; }, 0); } if (resolvedSessionId) { setCurrentSessionId(resolvedSessionId); // Acquire edit lock for initial session on mount acquireSessionLock(resolvedSessionId).then((acquired) => { if (acquired) startLockHeartbeat(resolvedSessionId); }); } } catch (error) { // Ignore history load failures. } }; loadChatHistory(); // Only run on mount / postId change — NOT on currentSessionId change // Session switches are handled by openSessionById directly }, [postId]); // MEMANTO: Restore session from memory when post editor loads. React.useEffect(() => { if (!postId || memantoRestoreFetchedRef.current) { return; } memantoRestoreFetchedRef.current = true; fetch(`${wpAgenticWriter.apiUrl}/memanto/restore?post_id=${postId}`, { headers: { "X-WP-Nonce": wpAgenticWriter.nonce }, }) .then((r) => (r.ok ? r.json() : Promise.reject(r))) .then((data) => { if (data?.restored) { wpawLog.info("MEMANTO: Session restored", data); setMemantoRestore({ restored: true, summary: data.summary || "", memories: data.memories || [], preferences: data.preferences || [], systemMessage: data.system_message || "", }); } }) .catch(() => { // Graceful degradation — ignore restore failures. }); }, [postId]); // MEMANTO: Apply user preferences to new post config (carry-over). React.useEffect(() => { if ( !postId || !memantoRestore.restored || !memantoRestore.preferences?.length || !configHydratedRef.current ) { return; } // Only apply preferences that aren't already set by the post config. setPostConfig((prev) => { let changed = false; const updated = { ...prev }; for (const pref of memantoRestore.preferences) { // Parse "User preference: tone=X, audience=Y, length=Z, language=W" const content = pref.content || ""; const toneMatch = content.match(/tone\s*=\s*([^,\n]+)/i); const audienceMatch = content.match(/audience\s*=\s*([^,\n]+)/i); const lengthMatch = content.match( /(?:article_)?length\s*=\s*([^,\n]+)/i, ); const langMatch = content.match(/language\s*=\s*([^,\n]+)/i); if ( toneMatch && !prev.tone && "default" !== toneMatch[1].trim().toLowerCase() ) { updated.tone = toneMatch[1].trim(); changed = true; } if ( audienceMatch && !prev.audience && "general" !== audienceMatch[1].trim().toLowerCase() ) { updated.audience = audienceMatch[1].trim(); changed = true; } if ( lengthMatch && prev.article_length === "medium" && "medium" !== lengthMatch[1].trim().toLowerCase() ) { updated.article_length = lengthMatch[1].trim(); changed = true; } if ( langMatch && prev.language === "auto" && "auto" !== langMatch[1].trim().toLowerCase() ) { updated.language = langMatch[1].trim(); changed = true; } } return changed ? updated : prev; }); }, [postId, memantoRestore.restored, isConfigLoading]); const loadPostSessions = async () => { const headers = { "X-WP-Nonce": wpAgenticWriter.nonce, }; let postSessions = []; let unassignedSessions = []; const fetchUserSessionsByStatus = async (status) => { const response = await fetch( `${wpAgenticWriter.apiUrl}/conversations?status=${status}&limit=50`, { method: "GET", headers, }, ); if (!response.ok) { return []; } const data = await response.json(); return Array.isArray(data?.sessions) ? data.sessions : []; }; const getContinuableUnassignedSessions = ( sessions, includeDraftLinked = false, ) => sessions.filter((s) => { const pid = Number(s?.post_id || 0); const postStatus = String(s?.post_status || "").toLowerCase(); return ( pid === 0 || postStatus === "auto-draft" || (includeDraftLinked && postStatus === "") ); }); if (postId) { const postRes = await fetch( `${wpAgenticWriter.apiUrl}/conversations/post/${postId}`, { method: "GET", headers, }, ); if (postRes.ok) { const postData = await postRes.json(); postSessions = Array.isArray(postData?.sessions) ? postData.sessions : []; } // Fallback: if this post has no linked sessions, surface active unassigned sessions // so users can continue unfinished work from other tabs or prior page loads. // This covers auto-draft AND draft posts that haven't had a session linked yet. if (postSessions.length === 0) { const [activeSessions, completedSessions] = await Promise.all([ fetchUserSessionsByStatus("active"), fetchUserSessionsByStatus("completed"), ]); // Completed is a legacy conversation status; still treat it as continuable. unassignedSessions = getContinuableUnassignedSessions( [...activeSessions, ...completedSessions], true, ); } } else { // New post flow: include unassigned/auto-draft sessions for recovery. const [activeSessions, completedSessions] = await Promise.all([ fetchUserSessionsByStatus("active"), fetchUserSessionsByStatus("completed"), ]); // Completed is a legacy conversation status; still treat it as continuable. unassignedSessions = getContinuableUnassignedSessions([ ...activeSessions, ...completedSessions, ]); } const merged = [...postSessions, ...unassignedSessions]; const deduped = []; const seen = new Set(); merged.forEach((session) => { const sid = session?.session_id || ""; if (!sid || seen.has(sid)) { return; } const storedMessageCount = Number( session?.message_count ?? (Array.isArray(session?.messages) ? session.messages.length : 0), ); if (storedMessageCount <= 0) { return; } seen.add(sid); deduped.push(session); }); setAvailableSessions(deduped); return deduped; }; const openSessionById = async (sessionId) => { if (!sessionId) { return; } const headers = { "X-WP-Nonce": wpAgenticWriter.nonce, }; setIsSessionActionLoading(true); try { // Release lock on previous session before switching if (currentSessionId && currentSessionId !== sessionId) { stopLockHeartbeat(); releaseSessionLock(currentSessionId); } const response = await fetch( `${wpAgenticWriter.apiUrl}/conversations/${sessionId}`, { method: "GET", headers, }, ); if (!response.ok) { throw new Error("Failed to load session"); } const data = await response.json(); // Cancel any pending message-save debounce from the previous session // to prevent stale timers from overwriting the new session's data. if (messagesSaveTimeoutRef.current) { clearTimeout(messagesSaveTimeoutRef.current); messagesSaveTimeoutRef.current = null; } isHydratingSessionRef.current = true; setCurrentSessionId(sessionId); const sessionMessages = Array.isArray(data?.messages) ? data.messages : []; // If session has no messages, try fetching from the conversations/post endpoint // as a recovery mechanism (messages may be stored under post relationship) if ( sessionMessages.length === 0 && data?.post_id && Number(data.post_id) > 0 ) { wpawLog.warn( "Session has 0 messages, attempting post-based recovery:", sessionId, ); try { const postSessionRes = await fetch( `${wpAgenticWriter.apiUrl}/conversation/${data.post_id}`, { method: "GET", headers, }, ); if (postSessionRes.ok) { const postSessionData = await postSessionRes.json(); if ( Array.isArray(postSessionData?.messages) && postSessionData.messages.length > 0 ) { sessionMessages.push(...postSessionData.messages); } } } catch (e) { // Non-fatal recovery attempt } } lastPersistedMessagesRef.current = JSON.stringify( sanitizeMessagesForStorage(sessionMessages), ); hydrateSessionStateFromMessages(sessionMessages); setMessages(sessionMessages); setShowWelcome(false); // Auto-link unassigned session to current post for continuity const sessionPostId = Number(data?.post_id || 0); if (postId && postId > 0 && sessionPostId === 0) { fetch( `${wpAgenticWriter.apiUrl}/conversations/${sessionId}/link-post`, { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ postId: postId }), }, ).catch(() => {}); // Non-blocking } // Acquire edit lock for this session (multi-tab safety) const lockAcquired = await acquireSessionLock(sessionId); if (lockAcquired) { startLockHeartbeat(sessionId); } setTimeout(() => { isHydratingSessionRef.current = false; }, 0); } catch (error) { isHydratingSessionRef.current = false; setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Error: Failed to load selected session.", }, ]); } finally { setIsSessionActionLoading(false); } }; 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 hasTitleMention = (mentionTokens) => { return ( Array.isArray(mentionTokens) && mentionTokens.some( (token) => normalizeMentionToken(String(token).replace("@", "")) === "title", ) ); }; const handleTitleRefinement = async ( rawMessage, mentionTokens, options = {}, ) => { const { skipUserMessage = false } = options; const instruction = stripMentionsFromText(rawMessage || ""); if (!instruction) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Please add title instruction after @title. Example: @title tulis ulang, gunakan focus keyword di awal.", }, ]); return false; } if (!skipUserMessage) { setMessages((prev) => [...prev, { role: "user", content: rawMessage }]); } const operationController = beginAgentOperation( "title", "title refinement", ); setIsLoading(true); setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "refining", message: "Refining title...", timestamp: new Date(), }, ]); try { const response = await fetch(`${wpAgenticWriter.apiUrl}/refine-title`, { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ postId: postId, sessionId: currentSessionId, instruction: instruction, }), signal: operationController.signal, }); const data = await response.json(); if (!response.ok) { throw new Error(data?.message || "Failed to refine title"); } if (data?.title) { dispatch("core/editor").editPost({ title: data.title }); } if (data?.cost) { setCost({ ...cost, session: cost.session + Number(data.cost || 0) }); } applyProviderMetadata(data); setMessages((prev) => { const next = [...prev]; const timelineIndex = findLastActiveTimelineIndex(next); if (timelineIndex !== -1) { next[timelineIndex] = { ...next[timelineIndex], status: "complete", message: "Title refined successfully.", completedAt: new Date(), }; } next.push({ role: "assistant", content: `Updated title: ${data.title || ""}`, }); return next; }); return true; } catch (error) { if (timeout) { clearTimeout(timeout); } if (isAbortError(error)) { setMessages((prev) => { const next = [...prev]; const timelineIndex = findLastActiveTimelineIndex(next); if (timelineIndex !== -1) { next[timelineIndex] = { ...next[timelineIndex], status: "stopped", message: "Title refinement stopped.", }; } return next; }); return false; } setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Error: " + (error.message || "Failed to refine title"), }, ]); return false; } finally { setIsLoading(false); finishAgentOperation("title"); } }; 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((prev) => [ ...prev, { role: "user", content: originalMessage }, ]); await handleChatRefinement(refinementMessage, [newBlock.clientId], { skipUserMessage: true, useDiffPlan: false, }); }; const streamGeneratePlan = async (request, options = {}) => { const { resume = false, suggestKeywords = agentMode === "planning" } = options; const normalizedRequest = { ...request, postConfig: postConfig, chatHistory: buildChatHistoryPayload(), }; lastGenerationRequestRef.current = normalizedRequest; const operationType = agentMode === "planning" ? "planning" : "generation"; const operationController = beginAgentOperation( operationType, operationType === "planning" ? "outline generation" : "article generation", ); setIsLoading(true); // Capture snapshot before generation (only if not resuming) if (!resume) { pushUndoSnapshot("Article Generation"); } let timeout = null; 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 }), signal: operationController.signal, }, ); if (!response.ok) { const error = await response.json(); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( error, "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); return; } const reader = registerActiveReader(response.body.getReader()); const decoder = new TextDecoder(); timeout = setTimeout(() => { if (isLoading) { wpawLog.error("Generation timeout - no response received"); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( "cURL error 28: Operation timed out after 120000 milliseconds", "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); reader.cancel(); } }, 120000); while (true) { if (stopExecutionRef.current || operationController.signal.aborted) { await reader.cancel().catch(() => {}); throw new DOMException("Operation stopped by user", "AbortError"); } 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, { suggestKeywords }); } } else if (data.type === "title_update") { dispatch("core/editor").editPost({ title: data.title }); } else if (data.type === "status") { if (data.status === "complete") { continue; } setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon, }; } return newMessages; }); } else if ( data.type === "conversational" || data.type === "conversational_stream" ) { const cleanContent = (data.content || "") .replace(/~~~ARTICLE~+/g, "") .replace(/~~~ARTICLE~~~[\r\n]*/g, "") .trim(); if ( !cleanContent || shouldSkipPlanningCompletion(cleanContent) ) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === "timeline") { updateOrCreateTimelineEntry(cleanContent); } else if (data.type === "conversational") { setMessages((prev) => [ ...prev, { role: "assistant", content: cleanContent }, ]); } else { setMessages((prev) => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if ( newMessages[lastIdx] && newMessages[lastIdx].role === "assistant" ) { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent, }; } else { newMessages.push({ role: "assistant", content: cleanContent, }); } return newMessages; }); } } else if (data.type === "block") { const { insertBlocks } = dispatch("core/block-editor"); let newBlock; if (data.block.blockName === "core/paragraph") { const content = data.block.innerHTML?.match(/

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

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

    (.*?)<\/p>/)?.[1] || ""; newBlock = wp.blocks.createBlock("core/quote", { value: content, }); } 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") { applyProviderMetadata(data); 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: formatAiErrorMessage( data.message || "An error occurred during article generation", "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); } } catch (parseError) { wpawLog.error( "Failed to parse streaming data:", line, parseError, ); } } } } if (timeout) { clearTimeout(timeout); } } catch (error) { if (isAbortError(error)) { setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "stopped", message: "Generation stopped.", }; } return newMessages; }); return; } wpawLog.error("Article generation error:", error); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage(error, "Failed to generate article"), canRetry: true, retryType: "generation", }, ]); } finally { setIsLoading(false); finishAgentOperation(operationType); } }; const retryLastGeneration = () => { if (!lastGenerationRequestRef.current) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Cannot retry because the original generation request is no longer available. Please send the request again.", }, ]); 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) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Cannot retry because the original writing request is no longer available. Please start writing again.", }, ]); return; } executePlanFromCard({ retry: true }); }; const retryLastRefinement = () => { if (!lastRefineRequestRef.current) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Cannot retry because the original refinement request is no longer available. Please send the refinement again.", }, ]); 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) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Cannot retry because the original chat request is no longer available. Please send the message again.", }, ]); return; } const userMessage = lastChatRequestRef.current.message; // Remove the last error message setMessages((prev) => prev.filter((m) => !(m.type === "error" && m.retryType === "chat")), ); setIsLoading(true); const retryOperationController = beginAgentOperation( "chat", "chat retry", ); 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, sessionId: currentSessionId, type: "chat", stream: true, postConfig: postConfig, }), signal: retryOperationController.signal, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to chat"); } const reader = registerActiveReader(response.body.getReader()); const decoder = new TextDecoder(); let streamBuffer = ""; let fullContent = ""; let streamError = null; let lastDataTime = Date.now(); let heartbeatShown = false; // Heartbeat: show reassurance if no data for 30s const heartbeatInterval = setInterval(() => { if (Date.now() - lastDataTime > 30000 && !heartbeatShown) { heartbeatShown = true; setMessages((prev) => [ ...prev, { role: "system", type: "timeline", status: "active", message: "⏳ Still waiting for response — the model is processing...", timestamp: new Date(), }, ]); } }, 10000); try { while (true) { if ( stopExecutionRef.current || retryOperationController.signal.aborted ) { throw new DOMException("Chat retry stopped", "AbortError"); } const { done, value } = await reader.read(); if (done) break; lastDataTime = Date.now(); heartbeatShown = false; 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") { streamError = new Error(data.message || "Chat error"); break; } 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") { // Apply provider metadata from completion. applyProviderMetadata(data); setMessages((prev) => { const lastMsg = prev[prev.length - 1]; if (lastMsg && lastMsg.role === "assistant") { return [ ...prev.slice(0, -1), { ...lastMsg, isStreaming: false }, ]; } return prev; }); // Extract ALL focus keyword suggestions from completed response. if (fullContent) { const suggestions = extractFocusKeywordSuggestions(fullContent); if (suggestions.length > 0) { addFocusKeywordSuggestions(suggestions); } } } else if (data.type === "provider" && data.fallback_used) { // Show in-chat provider fallback warning setMessages((prev) => [ ...prev, { role: "system", type: "timeline", status: "active", message: `⚠️ ${data.selectedProvider || "Selected provider"} unavailable — using ${data.provider || "fallback"}`, timestamp: new Date(), }, ]); } } catch (e) { wpawLog.error("Failed to parse retry streaming data:", line, e); } } if (streamError) { throw streamError; } } if ( stopExecutionRef.current || retryOperationController.signal.aborted ) { throw new DOMException("Chat retry stopped", "AbortError"); } } finally { clearInterval(heartbeatInterval); } } catch (error) { if ( isAbortError(error) || stopExecutionRef.current || retryOperationController.signal.aborted ) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Chat retry stopped by user.", timestamp: new Date(), }, ]); } else { const errorMsg = formatAiErrorMessage(error, "Failed to chat"); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: errorMsg, canRetry: true, retryType: "chat", }, ]); } } finally { setIsLoading(false); finishAgentOperation("chat"); } }; 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, ...(action.start ? { start: parseInt(action.start, 10) } : {}), }, listItems, ); } if (blockType === "core/code") { return wp.blocks.createBlock("core/code", { content: content, language: action.language || "text", }); } if (blockType === "core/paragraph") { if (content.includes("<") && content.includes(">")) { // Contains HTML tags, parse it into an array of blocks return wp.blocks.rawHandler({ HTML: content }); } } 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 : ""; const before = getBlockPreviewById(action.blockId); const beforePreview = before ? before : ""; 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}`, blockId, viewInEditor: true, }; case "replace": return { title: `Replace${targetLabel}${type}`, blockId, viewInEditor: true, }; case "change_type": return { title: `Change type${targetLabel}${type}`, blockId, viewInEditor: true, }; case "insert": return { title: `Insert new block${type}`, blockId, viewInEditor: true, }; case "insert_before": return { title: `Insert before${targetLabel}${type}`, blockId, viewInEditor: true, }; case "insert_after": return { title: `Insert after${targetLabel}${type}`, blockId, viewInEditor: true, }; default: return { title: `Unknown action: ${action.action}` }; } }; 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, sessionId: currentSessionId, 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, sessionId: currentSessionId, }), }, ); if (!response.ok) { throw new Error("Summarization failed"); } const data = await response.json(); applyProviderMetadata(data); if (data.tokens_saved > 0) { wpawLog.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) { wpawLog.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, sessionId: currentSessionId, }), }, ); if (!response.ok) { let message = "Intent detection failed"; try { const error = await response.json(); message = error?.message || message; } catch (parseError) { // Keep the fallback message if the error response is not JSON. } throw new Error(message); } const data = await response.json(); applyProviderMetadata(data); return { intent: data.intent || "continue_chat", cost: data.cost || 0, }; } catch (error) { wpawLog.error( "Intent detection error:", formatAiErrorMessage(error, "Intent detection failed"), ); 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) { wpawLog.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, suggestKeywords = agentMode === "planning" } = 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 (suggestKeywords && 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, sessionId: currentSessionId, 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 and apply provider metadata if (data.cost) { setCost({ ...cost, session: cost.session + data.cost }); } applyProviderMetadata(data); // Add assistant message about keyword suggestions setMessages((prev) => [ ...prev, { 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) { wpawLog.error("Keyword suggestion error:", error); // Silently fail - don't interrupt the workflow } }; const buildChatHistoryPayload = React.useCallback(() => { return messages .filter( (m) => (m.role === "user" || m.role === "assistant") && typeof m.content === "string" && m.content.trim(), ) .filter((m) => m.type !== "plan") .map((m) => ({ role: m.role, content: m.content.trim().slice(0, 2000), })) .slice(-10); }, [messages]); const getLastUserMessageText = React.useCallback(() => { for (let i = messages.length - 1; i >= 0; i -= 1) { const m = messages[i]; if ( m?.role === "user" && typeof m.content === "string" && m.content.trim() ) { return m.content.trim(); } } return ""; }, [messages]); 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 getPlanRuntimeSummary = (plan = currentPlanRef.current) => { const sections = Array.isArray(plan?.sections) ? plan.sections : []; const done = sections.filter( (section) => section.status === "done", ).length; const inProgress = sections.filter( (section) => section.status === "in_progress", ).length; const pending = Math.max(0, sections.length - done); return { total: sections.length, done, inProgress, pending, label: sections.length > 0 ? `${done}/${sections.length} written` : "No outline", }; }; const getPlanId = (plan = currentPlanRef.current) => { return plan?.id || plan?.meta?.id || plan?.title || ""; }; const classifyAgentIntent = (message) => { const text = String(message || "").toLowerCase(); const outlinePattern = /\b(?:out?line|plan|structure|kerangka|rencana)(?:\s*[- ]?\s*(?:nya|kan))?\b/i; if ( /@[a-z0-9-]/i.test(message) || hasTitleMention(extractMentionsFromText(message)) ) { return "targeted_refinement"; } if ( /\b(meta description|meta title|seo audit|seo score|keyword density|schema|faq)\b/i.test( text, ) ) { if (/\b(meta description|description)\b/i.test(text)) { return "generate_meta"; } return "seo_audit"; } if ( /\b(continue|resume|start writing|write article|write it|generate article|lanjut|tulis artikel|buat artikel)\b/i.test( text, ) ) { return "write"; } if (outlinePattern.test(text)) { return "outline"; } if ( /\b(refine|rewrite|improve|polish|fix|ai-ish|aiish|slop|humanize|natural|tone|clarity|rapikan|perbaiki)\b/i.test( text, ) ) { return "refine"; } return "chat"; }; const decideAgentAction = (message) => { const intent = classifyAgentIntent(message); const planSummary = getPlanRuntimeSummary(); const refineableBlocks = getRefineableBlocks(); const hasContent = refineableBlocks.length > 0; const hasPlan = Boolean(currentPlanRef.current && planSummary.total > 0); if (intent === "generate_meta") { return { action: "generate_meta", mode: "seo", reason: "SEO meta request", }; } if (intent === "seo_audit") { return { action: "seo_audit", mode: "seo", reason: "SEO analysis request", }; } if (intent === "targeted_refinement") { return { action: "targeted_refinement", mode: "refinement", reason: "Block mention detected", }; } if (intent === "write" && hasPlan && planSummary.pending > 0) { return { action: "execute_plan", mode: "writing", reason: "Outline has pending sections", }; } if ((intent === "write" || intent === "outline") && !hasContent) { return { action: "create_outline", mode: "planning", reason: "Fresh post needs outline first", }; } if (intent === "outline") { return { action: hasPlan ? "revise_outline" : "create_outline", mode: "planning", reason: hasPlan ? "Existing outline can be revised" : "Outline requested", }; } if (intent === "refine" && hasContent) { return { action: "article_refinement", mode: "refinement", reason: "Content refinement requested", }; } return { action: "chat", mode: "chat", reason: "Conversation" }; }; 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 yet. Ask the agent for an outline first, then it can continue into writing.", }, ]); setIsLoading(false); return; } const plan = currentPlanRef.current; // Confirmation: warn if editor already has content blocks const existingBlocks = select("core/block-editor").getBlocks(); const hasExistingContent = existingBlocks.some( (b) => b.name !== "core/paragraph" || (b.attributes?.content && b.attributes.content.trim().length > 0), ); if (hasExistingContent && !options.skipConfirm) { const pendingSections = Array.isArray(plan?.sections) ? plan.sections.filter((section) => section.status !== "done").length : 0; const confirmed = window.confirm( `This will write ${pendingSections} sections into the editor. Existing content will be preserved below the new content.\n\nContinue?`, ); if (!confirmed) { return; } } setAgentMode("writing"); 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(), }, ]); persistWritingStatePatch({ status: "completed", current_section_index: Array.isArray(plan?.sections) ? plan.sections.length : 0, sections_written: Array.isArray(plan?.sections) ? plan.sections .map((section) => section.id || section.heading || "") .filter(Boolean) : [], plan_id: getPlanId(plan), resume_token: "", }); setAgentMode("chat"); return; } const { retry = false } = options; lastExecuteRequestRef.current = { postId: postId, sessionId: currentSessionId, stream: true, postConfig: postConfig, detectedLanguage: detectedLanguage, chatHistory: messages.filter((m) => m.role !== "system"), }; // Reset stop flag stopExecutionRef.current = false; setExecutionStopped(false); const operationController = beginAgentOperation("writing", "writing"); setIsLoading(true); persistWritingStatePatch({ status: "in_progress", current_section_index: 0, sections_written: retry ? writingState.sections_written : [], plan_id: getPlanId(plan), resume_token: "", }); 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), signal: operationController.signal, }, ); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to execute outline"); } const reader = registerActiveReader(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.", }, ]); persistWritingStatePatch({ status: "failed", plan_id: getPlanId(currentPlanRef.current), resume_token: activeSectionIdRef.current || "", }); setIsLoading(false); reader.cancel(); } }, 120000); while (true) { // Check if execution should stop if (stopExecutionRef.current || operationController.signal.aborted) { await reader.cancel().catch(() => {}); 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; persistWritingStatePatch({ status: "paused", current_section_index: completedCount, sections_written: plan?.sections ?.filter((s) => s.status === "done") .map((s) => s.id || s.heading || "") .filter(Boolean) || [], plan_id: getPlanId(plan), resume_token: activeSectionIdRef.current || "", }); 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"); persistWritingStatePatch({ status: "in_progress", current_section_index: Number(data.index || 0), plan_id: getPlanId(currentPlanRef.current), resume_token: data.sectionId || "", }); } 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); const writtenSectionIds = Array.isArray( currentPlanRef.current?.sections, ) ? currentPlanRef.current.sections .filter((section) => section.status === "done") .map((section) => section.id || section.heading || "") .filter(Boolean) : [ ...new Set( [ ...(writingState.sections_written || []), data.sectionId, ].filter(Boolean), ), ]; persistWritingStatePatch({ status: "in_progress", current_section_index: writtenSectionIds.length, sections_written: writtenSectionIds, plan_id: getPlanId(currentPlanRef.current), resume_token: "", }); // Check if execution should stop after section completes if (stopExecutionRef.current) { await reader.cancel().catch(() => {}); clearTimeout(timeout); setExecutionStopped(true); setIsLoading(false); persistWritingStatePatch({ status: "paused", current_section_index: writtenSectionIds.length, sections_written: writtenSectionIds, plan_id: getPlanId(currentPlanRef.current), resume_token: data.sectionId || "", }); 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 }); } applyProviderMetadata(data); 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("chat"); persistWritingStatePatch({ status: "completed", current_section_index: Array.isArray( currentPlanRef.current?.sections, ) ? currentPlanRef.current.sections.length : writingState.current_section_index, sections_written: Array.isArray( currentPlanRef.current?.sections, ) ? currentPlanRef.current.sections .map((section) => section.id || section.heading || "") .filter(Boolean) : writingState.sections_written, plan_id: getPlanId(currentPlanRef.current), resume_token: "", }); setIsLoading(false); } else if (data.type === "error") { clearTimeout(timeout); throw new Error(data.message || "Failed to execute outline"); } } catch (parseError) { wpawLog.error( "Failed to parse streaming data:", line, parseError, ); } } } clearTimeout(timeout); // If stream ended without a 'complete' event, deactivate lingering timeline entries setMessages((prev) => { const hasActive = prev.some( (m) => m.type === "timeline" && m.status && !["complete", "inactive", "stopped"].includes(m.status), ); if (hasActive) { return deactivateActiveTimelineEntries(prev); } return prev; }); } catch (error) { if (isAbortError(error) || stopExecutionRef.current) { 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; persistWritingStatePatch({ status: "paused", current_section_index: completedCount, sections_written: plan?.sections ?.filter((s) => s.status === "done") .map((s) => s.id || s.heading || "") .filter(Boolean) || [], plan_id: getPlanId(plan), resume_token: activeSectionIdRef.current || "", }); 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\nCompleted: ${completedCount} section${completedCount !== 1 ? "s" : ""}\nPending: ${pendingCount} section${pendingCount !== 1 ? "s" : ""}\n\nYour generated content has been preserved in the editor.`, showResumeActions: true, pendingCount: pendingCount, }, ]); return; } setAgentMode(currentPlanRef.current ? "planning" : "chat"); persistWritingStatePatch({ status: "failed", plan_id: getPlanId(currentPlanRef.current), resume_token: activeSectionIdRef.current || "", }); setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "error", content: formatAiErrorMessage(error, "Failed to execute outline"), canRetry: true, retryType: "execute", }, ]); } finally { setIsLoading(false); finishAgentOperation("writing"); } }; const handleStopExecution = () => { if (!isLoading && !isSeoAuditing && !isGeneratingMeta) return; stopExecutionRef.current = true; setExecutionStopped(true); markActiveOperationStopping(); if (activeOperationRef.current?.type === "writing") { persistWritingStatePatch({ status: "paused", plan_id: getPlanId(currentPlanRef.current), resume_token: activeSectionIdRef.current || "", }); } if (activeReaderRef.current) { activeReaderRef.current.cancel().catch(() => {}); } if ( activeAbortControllerRef.current && !activeAbortControllerRef.current.signal.aborted ) { activeAbortControllerRef.current.abort(); } }; const clearChatContext = async () => { if (isLoading) { return; } const confirmMessage = "Start a new agent session for this post? The current session will stay available in Sessions."; if (!window.confirm(confirmMessage)) { return; } try { setIsSessionActionLoading(true); const response = await fetch( wpAgenticWriter.apiUrl + "/conversations", { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ post_id: postId }), }, ); if (!response.ok) { throw new Error("Failed to create a new conversation"); } const data = await response.json(); if (data?.session_id) { setCurrentSessionId(data.session_id); } await loadPostSessions(); setMessages([]); setInClarification(false); setQuestions([]); setCurrentQuestionIndex(0); setAnswers([]); setPendingRefinement(null); setPendingEditPlan(null); streamTargetRef.current = null; } catch (error) { if (isAbortError(error) || stopExecutionRef.current) { setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "stopped", message: "Refinement stopped by user.", }; } return newMessages; }); setMessages((prev) => [ ...prev, { role: "assistant", content: "Refinement stopped. Already-applied block changes remain in the editor and can be undone from the top bar.", }, ]); return; } setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Error: Failed to start a new conversation.", }, ]); } finally { setIsSessionActionLoading(false); } }; const createBlocksFromSerialized = (block) => { if (!block || !block.blockName) { return null; } const attrs = { ...(block.attrs || {}) }; // Handle code blocks if ( block.blockName === "core/code" && !attrs.content && block.innerHTML ) { const match = block.innerHTML.match(/([\s\S]*?)<\/code>/i); if (match && match[1]) { attrs.content = match[1] .replace(/</g, "<") .replace(/>/g, ">") .replace(/&/g, "&") .replace(/"/g, '"'); } } // Handle table blocks - extract head and body from innerHTML if (block.blockName === "core/table" && block.innerHTML) { const headMatch = block.innerHTML.match(/([\s\S]*?)<\/thead>/i); const bodyMatch = block.innerHTML.match(/([\s\S]*?)<\/tbody>/i); if (headMatch || bodyMatch) { attrs.head = []; attrs.body = []; // Parse thead rows if (headMatch) { const headRows = headMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; headRows.forEach((row) => { const cells = []; const cellMatches = row.match(/([\s\S]*?)<\/t[hd]>/gi) || []; cellMatches.forEach((cell) => { const content = cell.replace(/<\/?t[hd]>/gi, ""); cells.push({ content, tag: "th" }); }); if (cells.length > 0) attrs.head.push({ cells }); }); } // Parse tbody rows if (bodyMatch) { const bodyRows = bodyMatch[1].match(/([\s\S]*?)<\/tr>/gi) || []; bodyRows.forEach((row) => { const cells = []; const cellMatches = row.match(/([\s\S]*?)<\/td>/gi) || []; cellMatches.forEach((cell) => { const content = cell.replace(/<\/?td>/gi, ""); cells.push({ content, tag: "td" }); }); if (cells.length > 0) attrs.body.push({ cells }); }); } } } // Handle button blocks from [CTA:...] syntax if ( block.blockName === "core/buttons" || block.blockName === "core/button" ) { if (block.blockName === "core/button") { return wp.blocks.createBlock("core/buttons", {}, [ wp.blocks.createBlock("core/button", attrs), ]); } } if (block.innerBlocks && block.innerBlocks.length > 0) { const innerBlocks = block.innerBlocks .map((innerBlock) => createBlocksFromSerialized(innerBlock)) .filter(Boolean); return wp.blocks.createBlock(block.blockName, attrs, innerBlocks); } return wp.blocks.createBlock(block.blockName, attrs); }; const reformatBlocks = async (blocksToReformat, originalMessage) => { if (isLoading) { return; } if (!blocksToReformat || blocksToReformat.length === 0) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "No blocks found to reformat.", }, ]); return; } setIsLoading(true); setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "refining", message: `Reformatting ${blocksToReformat.length} block(s)...`, timestamp: new Date(), }, ]); try { const response = await fetch( wpAgenticWriter.apiUrl + "/reformat-blocks", { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ blocks: blocksToReformat, postId: postId, sessionId: currentSessionId, }), }, ); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to reformat blocks"); } const data = await response.json(); applyProviderMetadata(data); 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, sessionId: currentSessionId, 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 }); } applyProviderMetadata(data); 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; } const { replaceBlocks, insertBlocks, removeBlocks, updateBlockAttributes, } = dispatch("core/block-editor"); // Remove temporary green diff blocks BEFORE applying changes if (pendingDiffBlockIds.length > 0) { removeBlocks(pendingDiffBlockIds); setPendingDiffBlockIds([]); } // Capture snapshot before applying changes pushUndoSnapshot("Apply Edit Plan"); 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 = [].concat(createBlockFromPlan(action)); replaceBlocks(action.blockId, newBlock); return; } if (action.action === "replace" && action.blockId) { const newBlock = [].concat(createBlockFromPlan(action)); replaceBlocks(action.blockId, newBlock); return; } if ( action.action === "insert" || action.action === "insert_after" || action.action === "insert_before" ) { if (action.blockId && existingIds.has(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 = [].concat(createBlockFromPlan(action)); insertBlocks(newBlock, insertIndex); } else { const newBlock = [].concat(createBlockFromPlan(action)); insertBlocks(newBlock, allBlocks.length); } } }); setPendingEditPlan(null); setMessages((prev) => [ ...prev, { role: "system", type: "timeline", status: "complete", message: "Changes applied.", }, ]); }; const cancelEditPlan = () => { const { removeBlocks, updateBlockAttributes } = dispatch("core/block-editor"); const allBlocks = select("core/block-editor").getBlocks(); if (pendingDiffBlockIds.length > 0) { removeBlocks(pendingDiffBlockIds); setPendingDiffBlockIds([]); } const planActions = pendingEditPlan ? normalizePlanActions(pendingEditPlan) : []; planActions.forEach((action) => { if ( (action.action === "replace" || action.action === "delete") && action.blockId ) { const oldBlock = allBlocks.find((b) => b.clientId === action.blockId); if (oldBlock && oldBlock.attributes.className) { const cleanClass = oldBlock.attributes.className .replace("wpaw-diff-removed", "") .trim(); updateBlockAttributes(action.blockId, { className: cleanClass }); } } }); 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) { wpawLog.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 = (options = {}) => { const { textOnly = false } = options; const allBlocks = select("core/block-editor").getBlocks(); const textBlockTypes = new Set([ "core/paragraph", "core/heading", "core/list", "core/quote", "core/pullquote", "core/code", "core/preformatted", "core/table", ]); return allBlocks.filter((block) => { if (!block.name || !block.name.startsWith("core/")) { return false; } if (textOnly && !textBlockTypes.has(block.name)) { 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 extractQuotedTermsFromMessage = (message) => { if (!message || typeof message !== "string") { return []; } const terms = []; const quoteRegex = /"([^"]+)"|'([^']+)'/g; let match; while ((match = quoteRegex.exec(message)) !== null) { const term = (match[1] || match[2] || "").trim().toLowerCase(); if (term && term.length <= 40) { terms.push(term); } } return [...new Set(terms)]; }; const getAllTextRefineableBlocks = () => getRefineableBlocks({ textOnly: true }); const selectLikelySlangBlocks = (message) => { const textBlocks = getAllTextRefineableBlocks(); const quotedTerms = extractQuotedTermsFromMessage(message); if (!quotedTerms.length) { return textBlocks; } const matches = textBlocks.filter((block) => { const content = (extractBlockPreview(block) || "").toLowerCase(); return quotedTerms.some((term) => content.includes(term)); }); // Fallback to all text blocks when heuristic finds nothing. return matches.length > 0 ? matches : textBlocks; }; const isAiSlopRequest = (message = "") => /\b(ai-ish|aiish|ai-style|ai style|slop|humanize|natural|robotic|generic|fluffy|formulaic|tone)\b/i.test( String(message), ); const getAiSlopFindingsForBlock = (block) => { const content = (extractBlockPreview(block) || "").trim(); if (!content) { return []; } const findings = []; const rules = [ { label: "formulaic contrast phrase", pattern: /\b(bukan sekadar|not just)\b/i, }, { label: "template-like conclusion phrase", pattern: /\b(pada akhirnya|in conclusion|to summarize|in summary|kesimpulannya)\b/i, }, { label: "instructional/meta leakage", pattern: /\b(refined version|key refinements|changes made|rationale|could you please share)\b/i, }, { label: "dash-heavy sentence style", pattern: /\s[—–-]\s/u }, { label: "generic AI phrase", pattern: /\b(delve|furthermore|moreover|crucial|paramount|landscape|testament|unlock|harness|leverage|seamless|robust)\b/i, }, { label: "generic marketing claim", pattern: /\b(in today's digital world|plays a vital role|it is important to note|when it comes to)\b/i, }, ]; rules.forEach((rule) => { if (rule.pattern.test(content)) { findings.push(rule.label); } }); if ( block.name === "core/heading" && /\b(introduction|conclusion|overview|benefits|key takeaways|final thoughts)\b/i.test( content, ) ) { findings.push("weak generic heading"); } return [...new Set(findings)]; }; const selectLikelyAiSlopBlocks = ( message, candidateBlocks = getAllTextRefineableBlocks(), ) => { if (!isAiSlopRequest(message)) { return candidateBlocks; } const matches = candidateBlocks .map((block) => ({ block, findings: getAiSlopFindingsForBlock(block), })) .filter((entry) => entry.findings.length > 0); return matches.length > 0 ? matches.map((entry) => entry.block) : []; }; const buildContextBlocksForRefinement = ( targetIds, normalizedAllBlocks, ) => { const targetSet = new Set(targetIds); return normalizedAllBlocks.filter((block, index) => { if (targetSet.has(block.clientId)) { return true; } const prev = normalizedAllBlocks[index + 1]; const next = normalizedAllBlocks[index - 1]; const neighborsTargeted = targetSet.has(prev?.clientId) || targetSet.has(next?.clientId); return neighborsTargeted && block.name === "core/heading"; }); }; const buildRefinementDiagnosis = (message, blocks = [], options = {}) => { const auditContext = options.auditContext || null; if (auditContext?.source === "seo_audit") { const candidateCount = Number( auditContext.candidateBlockCount || blocks.length || 0, ); const patternLabel = formatAuditPatternLabel(auditContext); const candidateLabel = formatCountLabel( candidateCount, "candidate block", ); const scopeNote = Number(auditContext.refineableBlockCount || 0) > candidateCount ? ` I am not sending the full article; I narrowed the scope from ${formatCountLabel(auditContext.refineableBlockCount, "refineable block")} to ${candidateLabel}.` : ""; return `Audit found ${patternLabel}. I mapped that audit signal to ${candidateLabel} in the editor.${scopeNote} I will report changed blocks separately after verification.`; } const genericHeadingPattern = /\b(introduction|conclusion|overview|benefits|key takeaways|final thoughts)\b/i; let aiishCount = 0; let weakHeadingCount = 0; let thinBlockCount = 0; blocks.forEach((block) => { const content = extractBlockPreview({ name: block.name, attributes: block.attributes || {}, innerBlocks: block.innerBlocks || [], }); const trimmed = (content || "").trim(); if (!trimmed) { return; } if ( block.name === "core/heading" && (trimmed.length < 18 || genericHeadingPattern.test(trimmed)) ) { weakHeadingCount += 1; } if ( block.name === "core/paragraph" && getAiSlopFindingsForBlock(block).length > 0 ) { aiishCount += 1; } if (trimmed.split(/\s+/).length < 14 && block.name !== "core/heading") { thinBlockCount += 1; } }); const issueParts = []; if (aiishCount > 0) { issueParts.push( `${aiishCount} AI-ish paragraph${aiishCount === 1 ? "" : "s"}`, ); } if (weakHeadingCount > 0) { issueParts.push( `${weakHeadingCount} weak heading${weakHeadingCount === 1 ? "" : "s"}`, ); } if (thinBlockCount > 0) { issueParts.push( `${thinBlockCount} thin block${thinBlockCount === 1 ? "" : "s"}`, ); } const targetText = blocks.length === 1 ? "1 block" : `${blocks.length} blocks`; const requestLabel = /\b(ai-ish|aiish|slop|humanize|natural)\b/i.test( message, ) ? "tone and AI-slop cleanup" : "the requested refinement"; if (issueParts.length === 0) { return `I inspected ${targetText}. I did not find obvious slop markers, so I will focus on ${requestLabel} while preserving structure.`; } return `I inspected ${targetText}. I found ${issueParts.join(", ")}. I will focus the refinement on ${requestLabel} and keep the surrounding structure stable.`; }; 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": // @all intentionally targets text-based content blocks only. getAllTextRefineableBlocks().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, auditContext = null, } = 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); const hasAllMention = mentions.some( (token) => normalizeMentionToken(token.replace("@", "")) === "all", ); let resolvedIds = blocksToRefine; if (hasAllMention && !blocksOverride) { const likelyBlocks = isAiSlopRequest(message) ? selectLikelyAiSlopBlocks(message) : selectLikelySlangBlocks(message); resolvedIds = likelyBlocks.map((block) => block.clientId); setMessages((prev) => [ ...prev, { role: "system", type: "timeline", status: "inactive", message: `@all scope narrowed to ${resolvedIds.length} likely block(s) based on the request.`, timestamp: new Date(), }, ]); } if (resolvedIds.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; } if ( hasAllMention && resolvedIds.length >= REFINEMENT_ALL_CONFIRM_THRESHOLD ) { const confirmed = await requestRefineAllConfirmation( resolvedIds.length, ); if (!confirmed) { setMessages((prev) => [ ...prev, { role: "system", type: "timeline", status: "inactive", message: `Cancelled @all refinement (${resolvedIds.length} target blocks).`, timestamp: new Date(), }, ]); setIsLoading(false); return; } } const effectiveUseDiffPlan = hasAllMention ? false : useDiffPlan; const shouldUseSelectiveRefine = hasAllMention || (isAiSlopRequest(message) && resolvedIds.length > 1); 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 = resolvedIds .map((clientId) => normalizedAllBlocks.find((block) => block.clientId === clientId), ) .filter(Boolean); const contextBlocksForApi = buildContextBlocksForRefinement( resolvedIds, normalizedAllBlocks, ); const refinementDiagnosis = buildRefinementDiagnosis( message, blocksToRefineData, { auditContext }, ); const isAuditRefinement = auditContext?.source === "seo_audit"; const targetLabel = isAuditRefinement ? `${resolvedIds.length} audit candidate block(s)` : `${resolvedIds.length} block(s)`; // Add user message to chat if (!skipUserMessage) { setMessages((prev) => [...prev, { role: "user", content: message }]); } setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "checking", message: isAuditRefinement ? "Reading editor and mapping audit findings to candidate blocks..." : "Reading editor and inspecting target blocks...", timestamp: new Date(), }, { role: "assistant", type: "agent_diagnosis", content: refinementDiagnosis, }, { role: "system", type: "timeline", status: "refining", message: isAuditRefinement ? `Processing ${targetLabel}; changed blocks will be verified after streaming...` : `Refining ${targetLabel}...`, timestamp: new Date(), }, ]); setIsRefinementLocked(true); setRefiningBlockIds(resolvedIds); const operationController = beginAgentOperation( "refinement", "refinement", ); 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: contextBlocksForApi, postId: postId, sessionId: currentSessionId, stream: true, diffPlan: effectiveUseDiffPlan, selectiveRefine: shouldUseSelectiveRefine, auditContext: isAuditRefinement ? auditContext : null, postConfig: postConfig, chatHistory: messages.filter((m) => m.role !== "system"), }), signal: operationController.signal, }, ); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Refinement failed"); } // Handle streaming response streamTargetRef.current = null; const reader = registerActiveReader(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) { if (stopExecutionRef.current || operationController.signal.aborted) { await reader.cancel().catch(() => {}); throw new DOMException("Operation stopped by user", "AbortError"); } 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 === "status") { setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], message: data.message || newMessages[lastTimelineIndex].message, timestamp: new Date(), }; } return newMessages; }); } else if (data.type === "edit_plan") { setPendingEditPlan(data.plan); // Render Diff Blocks in Gutenberg const { insertBlocks, updateBlockAttributes } = dispatch("core/block-editor"); const allBlocks = select("core/block-editor").getBlocks(); const existingIds = new Set(allBlocks.map((b) => b.clientId)); const newDiffIds = []; const actions = normalizePlanActions(data.plan); // Reverse to not mess up indices when inserting multiple blocks const insertOffsets = {}; [...actions].reverse().forEach((action) => { if ( action.action === "replace" && action.blockId && existingIds.has(action.blockId) ) { const oldBlock = allBlocks.find( (b) => b.clientId === action.blockId, ); if (oldBlock) { const oldClass = oldBlock.attributes.className || ""; if (!oldClass.includes("wpaw-diff-removed")) { updateBlockAttributes(action.blockId, { className: (oldClass ? oldClass + " " : "") + "wpaw-diff-removed", }); } const generatedBlocks = [].concat( createBlockFromPlan(action), ); generatedBlocks.forEach((tb) => { const tempClass = tb.attributes.className || ""; tb.attributes.className = (tempClass ? tempClass + " " : "") + "wpaw-diff-added"; newDiffIds.push(tb.clientId); }); const baseIndex = allBlocks.findIndex( (b) => b.clientId === action.blockId, ); if (baseIndex !== -1) { insertBlocks(generatedBlocks, baseIndex + 1); } } } else if ( action.action === "delete" && action.blockId && existingIds.has(action.blockId) ) { const oldBlock = allBlocks.find( (b) => b.clientId === action.blockId, ); if (oldBlock) { const oldClass = oldBlock.attributes.className || ""; if (!oldClass.includes("wpaw-diff-removed")) { updateBlockAttributes(action.blockId, { className: (oldClass ? oldClass + " " : "") + "wpaw-diff-removed", }); } } } else if ( action.action === "insert" || action.action === "insert_after" || action.action === "insert_before" ) { const generatedBlocks = [].concat( createBlockFromPlan(action), ); generatedBlocks.forEach((tb) => { const tempClass = tb.attributes.className || ""; tb.attributes.className = (tempClass ? tempClass + " " : "") + "wpaw-diff-added"; newDiffIds.push(tb.clientId); }); if (action.blockId && existingIds.has(action.blockId)) { const baseIndex = allBlocks.findIndex( (b) => b.clientId === action.blockId, ); if (baseIndex !== -1) { const offsets = insertOffsets[action.blockId] || { before: 0, after: 0, }; let insertIndex = baseIndex; 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; insertBlocks(generatedBlocks, insertIndex); } } else { // Append to end of post if no blockId insertBlocks(generatedBlocks, allBlocks.length); } } }); setPendingDiffBlockIds(newDiffIds); 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); setRefiningBlockIds((prevIds) => prevIds.map((id) => id === blockData.clientId ? newBlock.clientId : id, ), ); if (sectionId) { removeSectionBlock(sectionId, blockData.clientId); upsertSectionBlock(sectionId, newBlock.clientId); updatedSectionIds.add(sectionId); } } } refinedCount++; } else if (data.type === "complete") { // Apply provider metadata from completion applyProviderMetadata(data); // Update timeline setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { const failedLabel = Number(data.failed || 0) > 0 ? `, ${Number(data.failed)} failed` : ""; const auditPatternLabel = formatAuditPatternLabel(auditContext); const auditCandidateLabel = formatCountLabel( auditContext?.candidateBlockCount || resolvedIds.length, "candidate block", ); const auditChangedLabel = formatCountLabel( refinedCount, "changed block", ); newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.aborted ? "error" : "complete", message: isAuditRefinement ? data.aborted ? `Audit fix stopped early: ${auditPatternLabel} -> ${auditCandidateLabel} inspected -> ${auditChangedLabel}${failedLabel}` : `Audit fix complete: ${auditPatternLabel} -> ${auditCandidateLabel} inspected -> ${auditChangedLabel}${failedLabel}` : data.aborted ? `Refinement stopped early: ${refinedCount} updated${failedLabel}` : `Refined ${refinedCount} block(s) successfully${failedLabel}`, timestamp: new Date(), }; } return newMessages; }); // Show completion message setMessages((prev) => [ ...prev, { role: "assistant", content: data.aborted ? isAuditRefinement ? `Audit fix stopped early after provider errors.\n\n- Audit signal: ${formatAuditPatternLabel(auditContext)}\n- Candidate scope: ${formatCountLabel(auditContext?.candidateBlockCount || resolvedIds.length, "candidate block")} inspected\n- Editor changes: ${formatCountLabel(refinedCount, "block")} changed${Number(data.failed || 0) > 0 ? `\n- Failed attempts: ${Number(data.failed)}` : ""}` : `⚠️ I stopped early after provider errors. Updated ${refinedCount} block(s)${Number(data.failed || 0) > 0 ? `, ${Number(data.failed)} failed` : ""}.` : isAuditRefinement ? `Audit fix complete.\n\n- Audit signal: ${formatAuditPatternLabel(auditContext)}\n- Candidate scope: ${formatCountLabel(auditContext?.candidateBlockCount || resolvedIds.length, "candidate block")} inspected\n- Editor changes: ${formatCountLabel(refinedCount, "block")} changed${Number(data.failed || 0) > 0 ? `\n- Failed attempts: ${Number(data.failed)}` : ""}\n\nVerification: changed blocks were written back to the editor and can be undone from the top bar.` : `✅ Done! I've refined ${refinedCount} block(s) as requested${Number(data.failed || 0) > 0 ? `, with ${Number(data.failed)} failed attempts` : ""}.\n\nVerification: updated blocks were written back to the editor and can be undone from the top bar.`, }, ]); // Update cost if (data.totalCost) { setCost({ ...cost, session: cost.session + data.totalCost, }); } updatedSectionIds.forEach((sectionId) => { saveSectionBlocks(sectionId); }); } } catch (e) { wpawLog.error("Failed to parse streaming data:", line, e); } } if (refinementFailed) { break; } } if (refinementFailed) { break; } } if (stopExecutionRef.current || operationController.signal.aborted) { throw new DOMException("Operation stopped by user", "AbortError"); } 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) { if (isAbortError(error) || stopExecutionRef.current) { setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "stopped", message: "Refinement stopped by user.", }; } return newMessages; }); setMessages((prev) => [ ...prev, { role: "assistant", content: "Refinement stopped. Already-applied block changes remain in the editor and can be undone from the top bar.", }, ]); return; } 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 { setIsRefinementLocked(false); setRefiningBlockIds([]); setIsLoading(false); finishAgentOperation("refinement"); } }; const renderRefineAllConfirmModal = () => { if (!refineAllConfirm.isOpen) { return null; } return wp.element.createElement( "div", { className: "wpaw-refine-confirm-overlay", role: "dialog", "aria-modal": "true", "aria-label": "Confirm large @all refinement", }, wp.element.createElement( "div", { className: "wpaw-refine-confirm-modal" }, wp.element.createElement( "div", { className: "wpaw-refine-confirm-title" }, "Confirm @all Refinement", ), wp.element.createElement( "div", { className: "wpaw-refine-confirm-body" }, `This will refine ${refineAllConfirm.blockCount} text block(s) in batches of 5. ` + "This may take time and consume API credits.", ), wp.element.createElement(CheckboxControl, { label: "Don’t ask again for this session", checked: refineAllConfirm.dontAskAgain, onChange: (checked) => { setRefineAllConfirm((prev) => ({ ...prev, dontAskAgain: Boolean(checked), })); }, }), wp.element.createElement( "div", { className: "wpaw-refine-confirm-actions" }, wp.element.createElement( Button, { isSecondary: true, onClick: () => resolveRefineAllConfirmation(false), }, "Cancel", ), wp.element.createElement( Button, { isPrimary: true, onClick: () => { if (refineAllConfirm.dontAskAgain) { skipRefineAllConfirmRef.current = true; } resolveRefineAllConfirmation(true); }, }, "Continue", ), ), ), ); }; // 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", }); } if (!query || "title".includes(query.toLowerCase())) { options.push({ id: "title", label: "@title", sublabel: "Refine post title with instruction", 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; } const agentRoute = decideAgentAction(userMessage); const effectiveAgentMode = agentRoute.mode || agentMode || "chat"; if ( ["chat", "planning", "writing"].includes(effectiveAgentMode) && effectiveAgentMode !== agentMode ) { setAgentMode(effectiveAgentMode); } if (agentRoute.action === "execute_plan") { setInput(""); setMessages((prev) => [ ...prev, { role: "user", content: userMessage }, ]); addActivityTimeline( "checking", "Checking outline and editor context...", ); await executePlanFromCard({ skipConfirm: true }); return; } if (agentRoute.action === "generate_meta") { setInput(""); setMessages((prev) => [ ...prev, { role: "user", content: userMessage }, ]); addActivityTimeline( "checking", "Reading article and generating meta description...", ); await generateMetaDescription(); return; } if (agentRoute.action === "seo_audit") { setInput(""); setMessages((prev) => [ ...prev, { role: "user", content: userMessage }, ]); addActivityTimeline( "checking", "Reading editor and running SEO audit...", ); await runSeoAudit(); return; } const parsedCommand = parseInsertCommand(userMessage); const commandMessage = parsedCommand ? parsedCommand.message : userMessage; const mentionTokens = extractMentionsFromText(commandMessage); const hasMentions = mentionTokens.length > 0; const titleMentioned = hasTitleMention(mentionTokens); const refineableBlocks = getRefineableBlocks(); const shouldShowPlan = effectiveAgentMode === "planning"; const generationLabel = effectiveAgentMode === "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((prev) => [ ...prev, { 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 (titleMentioned) { setInput(""); await handleTitleRefinement(userMessage, mentionTokens); return; } if ( effectiveAgentMode === "planning" && !hasMentions && currentPlanRef.current ) { setInput(""); setMessages((prev) => [ ...prev, { role: "user", content: userMessage }, ]); await revisePlanFromPrompt(userMessage); return; } if (effectiveAgentMode === "chat" && !hasMentions) { setInput(""); setMessages((prev) => [ ...prev, { role: "user", content: userMessage }, ]); const operationController = beginAgentOperation( "chat", "chat response", ); setIsLoading(true); // User message is NOT an AI suggestion - don't extract from user input // 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, sessionId: currentSessionId, type: "chat", stream: true, postConfig: postConfig, }), signal: operationController.signal, }); if (!response.ok) { const error = await response.json(); throw new Error(error.message || "Failed to chat"); } const reader = registerActiveReader(response.body.getReader()); const decoder = new TextDecoder(); let streamBuffer = ""; let streamError = null; streamTargetRef.current = null; while (true) { if ( stopExecutionRef.current || operationController.signal.aborted ) { await reader.cancel().catch(() => {}); throw new DOMException("Operation stopped by user", "AbortError"); } 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") { streamError = new Error(data.message || "Failed to chat"); break; } 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") { if (data.totalCost) { setCost({ ...cost, session: cost.session + data.totalCost, }); } applyProviderMetadata(data); // Extract ALL focus keyword suggestions from AI response setMessages((prev) => { const lastAssistantMsg = prev .filter((m) => m.role === "assistant") .pop(); if (lastAssistantMsg && lastAssistantMsg.content) { const suggestions = extractFocusKeywordSuggestions( lastAssistantMsg.content, ); if (suggestions.length > 0) { addFocusKeywordSuggestions(suggestions); } } return prev; }); } } catch (parseError) { wpawLog.error( "Failed to parse streaming data:", line, parseError, ); } } if (streamError) { throw streamError; } } // 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) { wpawLog.error( "Intent detection failed:", formatAiErrorMessage(intentError, "Intent detection failed"), ); } } catch (error) { if (isAbortError(error)) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Chat response stopped.", timestamp: new Date(), }, ]); // Continue to shared cleanup below. } else { const errorMsg = formatAiErrorMessage(error, "Failed to chat"); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: errorMsg, canRetry: true, retryType: "chat", }, ]); } } setIsLoading(false); finishAgentOperation("chat"); return; } if ( !hasMentions && refineableBlocks.length > 0 && agentRoute.action === "article_refinement" ) { // 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((prev) => [ ...prev, { 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 = isAiSlopRequest(userMessage) ? selectLikelyAiSlopBlocks(userMessage, refineableBlocks).map( (block) => block.clientId, ) : refineableBlocks.map((block) => block.clientId); if ( isAiSlopRequest(userMessage) && fallbackBlocks.length === 0 && !targetedBlocks && matchedSectionBlocks.length === 0 ) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "assistant", content: "I inspected the article and did not find blocks matching the AI-ish/slop detector, so I did not send the whole article to refinement.", }, ]); setIsLoading(false); return; } 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((prev) => [ ...prev, { role: "user", content: userMessage }, ]); const operationType = effectiveAgentMode === "planning" ? "planning" : "generation"; const operationController = beginAgentOperation( operationType, effectiveAgentMode === "planning" ? "outline generation" : "article generation", ); 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 let requestDetectedLanguage = detectedLanguage; 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, sessionId: currentSessionId, mode: "generation", postConfig: postConfig, chatHistory: buildChatHistoryPayload(), }), signal: operationController.signal, }, ); if (clarityResponse.ok) { const clarityData = await clarityResponse.json(); const clarityResult = clarityData.result; // Store detected language for article generation if (clarityResult.detected_language) { requestDetectedLanguage = 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; }); finishAgentOperation(operationType); return; } } // If clarity check fails, proceed with generation anyway } catch (clarityError) { if (isAbortError(clarityError)) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Generation stopped.", timestamp: new Date(), }, ]); setIsLoading(false); finishAgentOperation(operationType); return; } wpawLog.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 let timeout = null; 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, sessionId: currentSessionId, answers: [], autoExecute: effectiveAgentMode !== "planning", stream: true, articleLength: postConfig.article_length, detectedLanguage: requestDetectedLanguage, postConfig: postConfig, chatHistory: buildChatHistoryPayload(), }), signal: operationController.signal, }, ); if (!response.ok) { const error = await response.json(); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( error, "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); return; } // Handle streaming response streamTargetRef.current = null; const reader = registerActiveReader(response.body.getReader()); const decoder = new TextDecoder(); // Add timeout to detect hanging responses timeout = setTimeout(() => { if (isLoading) { wpawLog.error("Generation timeout - no response received"); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( "cURL error 28: Operation timed out after 120000 milliseconds", "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); reader.cancel(); } }, 120000); // 2 minute timeout while (true) { if ( stopExecutionRef.current || operationController.signal.aborted ) { await reader.cancel().catch(() => {}); throw new DOMException("Operation stopped by user", "AbortError"); } 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, { suggestKeywords: effectiveAgentMode === "planning", }); } } else if (data.type === "title_update") { dispatch("core/editor").editPost({ title: data.title }); } else if (data.type === "status") { if (data.status === "complete") { continue; } // Update timeline setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon, }; } return newMessages; }); } else if ( data.type === "conversational" || data.type === "conversational_stream" ) { // Remove article marker and clean content const cleanContent = (data.content || "") .replace(/~~~ARTICLE~+/g, "") .replace(/~~~ARTICLE~~~[\r\n]*/g, "") .trim(); // Skip if content is empty after cleaning if ( !cleanContent || shouldSkipPlanningCompletion(cleanContent) ) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === "timeline") { updateOrCreateTimelineEntry(cleanContent); } else { // This is actual conversational content - add as chat bubble if (data.type === "conversational") { setMessages((prev) => [ ...prev, { role: "assistant", content: cleanContent }, ]); } else { setMessages((prev) => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if ( newMessages[lastIdx] && newMessages[lastIdx].role === "assistant" ) { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent, }; } else { newMessages.push({ role: "assistant", content: cleanContent, }); } return newMessages; }); } } } else if (data.type === "block") { const { insertBlocks } = dispatch("core/block-editor"); let newBlock; if (data.block.blockName === "core/paragraph") { const content = data.block.innerHTML?.match(/

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

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

    (.*?)<\/p>/)?.[1] || ""; newBlock = wp.blocks.createBlock("core/quote", { value: content, }); } 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") { applyProviderMetadata(data); 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: effectiveAgentMode === "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: formatAiErrorMessage( data.message || "An error occurred during article generation", "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); } } catch (parseError) { wpawLog.error( "Failed to parse streaming data:", line, parseError, ); } } if (streamError) { throw streamError; } } } // Clear timeout when streaming completes normally clearTimeout(timeout); } catch (error) { if (timeout) { clearTimeout(timeout); } if (isAbortError(error)) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Generation stopped.", timestamp: new Date(), }, ]); } else { wpawLog.error("Article generation error:", error); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( error, "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); } setIsLoading(false); } finally { finishAgentOperation(operationType); } return; } // Has mentions - check if mentioned blocks exist let blocksToRefine = []; if (hasMentions) { blocksToRefine = resolveBlockMentions(mentionTokens); } if (hasMentions && blocksToRefine.length === 0) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "No valid blocks found to refine. Select a block and use @this, or target an existing block like @paragraph-1.", }, ]); setIsLoading(false); return; } 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((prev) => [...prev, { role: "user", content: userMessage }]); const fallbackOperationType = effectiveAgentMode === "planning" ? "planning" : "generation"; const fallbackOperationController = beginAgentOperation( fallbackOperationType, effectiveAgentMode === "planning" ? "outline generation" : "article generation", ); 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, sessionId: currentSessionId, answers: [], autoExecute: effectiveAgentMode !== "planning", stream: true, articleLength: postConfig.article_length, postConfig: postConfig, chatHistory: buildChatHistoryPayload(), }), signal: fallbackOperationController.signal, }, ); if (!response.ok) { const error = await response.json(); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( error, "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); return; } // Handle streaming response const reader = registerActiveReader(response.body.getReader()); const decoder = new TextDecoder(); while (true) { if ( stopExecutionRef.current || fallbackOperationController.signal.aborted ) { await reader.cancel().catch(() => {}); throw new DOMException("Operation stopped by user", "AbortError"); } 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 (effectiveAgentMode === "planning" && data.plan) { updateOrCreatePlanMessage(data.plan); } } else if (data.type === "title_update") { dispatch("core/editor").editPost({ title: data.title }); } else if (data.type === "status") { if (data.status === "complete") { continue; } // Update timeline setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon, }; } return newMessages; }); } else if ( data.type === "conversational" || data.type === "conversational_stream" ) { const cleanContent = (data.content || "") .replace(/~~~ARTICLE~+/g, "") .replace(/~~~ARTICLE~~~[\r\n]*/g, "") .trim(); if ( !cleanContent || shouldSkipPlanningCompletion(cleanContent) ) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === "timeline") { updateOrCreateTimelineEntry(cleanContent); } else if (data.type === "conversational") { setMessages((prev) => [ ...prev, { role: "assistant", content: cleanContent }, ]); } else { setMessages((prev) => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if ( newMessages[lastIdx] && newMessages[lastIdx].role === "assistant" ) { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent, }; } else { newMessages.push({ role: "assistant", content: cleanContent, }); } return newMessages; }); } } else if (data.type === "block") { const { insertBlocks } = dispatch("core/block-editor"); let newBlock; if (data.block.blockName === "core/paragraph") { const content = data.block.innerHTML?.match(/

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

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

    (.*?)<\/p>/)?.[1] || ""; newBlock = wp.blocks.createBlock("core/quote", { value: content, }); } 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") { applyProviderMetadata(data); 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: effectiveAgentMode === "planning" ? "Outline ready." : "Article generation complete!", }; } return newMessages; }); // Check for image placeholders and open modal if found if (effectiveAgentMode !== "planning") { setTimeout(() => { const blocks = select("core/block-editor").getBlocks(); const imagePlaceholders = blocks.filter( (block) => block.name === "core/image" && block.attributes["data-agent-image-id"], ); if (imagePlaceholders.length > 0) { window.dispatchEvent( new CustomEvent("wpaw:open-image-review-modal", { detail: { postId: postId, sessionId: currentSessionId, imageCount: imagePlaceholders.length, }, }), ); } }, 500); } } else if (data.type === "error") { throw new Error(data.message); } } catch (parseError) { wpawLog.error( "Failed to parse streaming data:", line, parseError, ); } } } } setTimeout(() => { setIsLoading(false); }, 1500); } catch (error) { if (isAbortError(error)) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Generation stopped.", timestamp: new Date(), }, ]); } else { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Error: " + error.message, }, ]); } setIsLoading(false); } finally { finishAgentOperation(fallbackOperationType); } }; // Submit answers and continue generation. const submitAnswers = async () => { if (isLoading) { return; } const resolvedPostConfig = buildPostConfigFromAnswers(answers); // 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("focus_keyword", configData.focus_keyword); updatePostConfig("seo_focus_keyword", configData.focus_keyword); } if (configData.secondary_keywords) { updatePostConfig( "seo_secondary_keywords", configData.secondary_keywords, ); } } catch (e) { wpawLog.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; } const submissionMode = agentMode || "chat"; const submissionOperationType = submissionMode === "planning" ? "planning" : "generation"; const submissionOperationController = beginAgentOperation( submissionOperationType, submissionMode === "planning" ? "outline generation" : "article generation", ); 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: submissionMode === "planning" ? "Creating outline..." : "Generating article...", timestamp: new Date(), }, ]); let timeout = null; try { const topic = getLastUserMessageText() || messages .map((m) => (typeof m.content === "string" ? m.content : "")) .filter(Boolean) .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, sessionId: currentSessionId, clarificationAnswers: answers, autoExecute: submissionMode !== "planning", stream: true, articleLength: resolvedPostConfig.article_length, detectedLanguage: detectedLanguage, postConfig: resolvedPostConfig, chatHistory: buildChatHistoryPayload(), }), signal: submissionOperationController.signal, }, ); if (!response.ok) { const error = await response.json(); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage(error, "Failed to generate plan"), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); return; } // Handle streaming response (similar to sendMessage) streamTargetRef.current = null; const reader = registerActiveReader(response.body.getReader()); const decoder = new TextDecoder(); // Add timeout to detect hanging responses timeout = setTimeout(() => { if (isLoading) { wpawLog.error("Generation timeout - no response received"); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( "cURL error 28: Operation timed out after 120000 milliseconds", "Failed to generate plan", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); reader.cancel(); } }, 120000); // 2 minute timeout while (true) { if ( stopExecutionRef.current || submissionOperationController.signal.aborted ) { await reader.cancel().catch(() => {}); throw new DOMException("Operation stopped by user", "AbortError"); } 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 (submissionMode === "planning" && data.plan) { updateOrCreatePlanMessage(data.plan); } } else if (data.type === "title_update") { dispatch("core/editor").editPost({ title: data.title }); } else if (data.type === "status") { if (data.status === "complete") { continue; } // Update timeline setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: data.status, message: data.message, icon: data.icon, }; } return newMessages; }); } else if ( data.type === "conversational" || data.type === "conversational_stream" ) { // Remove article marker and clean content const cleanContent = (data.content || "") .replace(/~~~ARTICLE~+/g, "") .replace(/~~~ARTICLE~~~[\r\n]*/g, "") .trim(); // Skip if content is empty after cleaning if ( !cleanContent || shouldSkipPlanningCompletion(cleanContent) ) { continue; } const streamTarget = streamTargetRef.current || resolveStreamTarget(cleanContent); if (!streamTarget) { continue; } streamTargetRef.current = streamTarget; if (streamTarget === "timeline") { updateOrCreateTimelineEntry(cleanContent); } else { // This is actual conversational content - add as chat bubble if (data.type === "conversational") { setMessages((prev) => [ ...prev, { role: "assistant", content: cleanContent }, ]); } else { setMessages((prev) => { const newMessages = [...prev]; const lastIdx = newMessages.length - 1; if ( newMessages[lastIdx] && newMessages[lastIdx].role === "assistant" ) { newMessages[lastIdx] = { ...newMessages[lastIdx], content: cleanContent, }; } else { newMessages.push({ role: "assistant", content: cleanContent, }); } return newMessages; }); } } } else if (data.type === "block") { // Insert blocks (same as above) const { insertBlocks } = dispatch("core/block-editor"); let newBlock; if (data.block.blockName === "core/paragraph") { const content = data.block.innerHTML?.match(/

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

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

    (.*?)<\/p>/)?.[1] || ""; newBlock = wp.blocks.createBlock("core/quote", { value: content, }); } 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") { applyProviderMetadata(data); 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: submissionMode === "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: formatAiErrorMessage( data.message || "An error occurred during article generation", "Failed to generate plan", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); } } catch (parseError) { wpawLog.error( "Failed to parse streaming data:", line, parseError, ); } } } // Clear timeout when streaming completes normally clearTimeout(timeout); } } catch (error) { if (timeout) { clearTimeout(timeout); } if (isAbortError(error)) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Generation stopped.", timestamp: new Date(), }, ]); } else { wpawLog.error("Article generation error:", error); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( error, "Failed to generate article", ), canRetry: true, retryType: "generation", }, ]); } setIsLoading(false); } finally { finishAgentOperation(submissionOperationType); } }; // 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("textarea", { className: "wpaw-custom-text-input", placeholder: "Type your answer here...", value: customValue, rows: 3, onChange: (e) => { const newAnswers = { ...answers }; newAnswers[customInputKey] = e.target.value; setAnswers(newAnswers); }, autoFocus: true, style: { resize: "vertical" }, }), ), ); }; // 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", ), ), ), ); }; const startNewConversation = async () => { if (isLoading || isSessionActionLoading) { return; } try { setIsSessionActionLoading(true); const response = await fetch( wpAgenticWriter.apiUrl + "/conversations", { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ post_id: postId || 0 }), }, ); if (!response.ok) { throw new Error("Failed to create a new conversation"); } const data = await response.json(); // Fully reset state for clean slate // Release lock on previous session + cancel pending debounce if (currentSessionId) { stopLockHeartbeat(); releaseSessionLock(currentSessionId); } if (messagesSaveTimeoutRef.current) { clearTimeout(messagesSaveTimeoutRef.current); messagesSaveTimeoutRef.current = null; } isHydratingSessionRef.current = true; if (data?.session_id) { setCurrentSessionId(data.session_id); } lastPersistedMessagesRef.current = JSON.stringify([]); setMessages([]); currentPlanRef.current = null; setAgentMode("chat"); setShowWelcome(false); setFocusKeywordSuggestions([]); setSelectedFocusKeyword(""); setProviderInfo(null); setMemantoRestore({ restored: false, summary: "", memories: [], preferences: [], systemMessage: "", }); // loadPostSessions filters out sessions with 0 messages, so the brand-new // session won't appear. Load existing sessions, then prepend the new one. const existingSessions = await loadPostSessions(); if (data?.session_id) { setAvailableSessions((prev) => { // Avoid duplicating if somehow already present if (prev.some((s) => s?.session_id === data.session_id)) { return prev; } return [data, ...prev]; }); // Acquire lock on the new session acquireSessionLock(data.session_id).then((acquired) => { if (acquired) startLockHeartbeat(data.session_id); }); } setTimeout(() => { isHydratingSessionRef.current = false; // Focus input if (inputRef.current) { inputRef.current.focus(); } }, 50); } catch (error) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Error: Failed to start a new conversation.", }, ]); } finally { setIsSessionActionLoading(false); } }; const deleteConversationSession = async (sessionId) => { if (!sessionId || isSessionActionLoading) { return; } if (!window.confirm("Delete this session permanently?")) { return; } try { setIsSessionActionLoading(true); const response = await fetch( `${wpAgenticWriter.apiUrl}/conversations/${sessionId}`, { method: "DELETE", headers: { "X-WP-Nonce": wpAgenticWriter.nonce, }, }, ); if (!response.ok) { throw new Error("Failed to delete session"); } const sessions = await loadPostSessions(); if (currentSessionId === sessionId) { const replacement = sessions[0]?.session_id || ""; setCurrentSessionId(replacement); setMessages( Array.isArray(sessions[0]?.messages) ? sessions[0].messages : [], ); } } catch (error) { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Error: Failed to delete session.", }, ]); } finally { setIsSessionActionLoading(false); } }; const getSessionDisplayTitle = (session, index) => { if (session?.title && session.title.trim()) { return session.title.trim(); } const firstUser = Array.isArray(session?.messages) ? session.messages.find( (m) => m?.role === "user" && typeof m?.content === "string" && m.content.trim(), ) : null; if (firstUser?.content) { return firstUser.content.trim().slice(0, 56); } const updatedRaw = session?.updated_at || session?.last_activity || ""; if (updatedRaw) { const d = new Date(updatedRaw); if (!Number.isNaN(d.getTime())) { return `Session ${index + 1} - ${d.toLocaleDateString()}`; } } return `Session ${index + 1}`; }; const getSessionContinuityLabel = (session) => { const status = String(session?.status || "active").toLowerCase(); if (status === "completed") { return "Continuable"; } if (status === "archived") { return "Archived"; } return "Active"; }; const getSessionDebugMeta = (session) => { const id = Number(session?.id || 0); const sid = String(session?.session_id || "-"); const pid = Number(session?.post_id || 0); const sessionStatus = String(session?.status || "active"); const postStatus = String(session?.post_status || "").toLowerCase(); const statusLabel = pid === 0 ? "unassigned" : postStatus || "unknown"; return `id: ${id || "-"} | sid: ${sid} | post_id: ${pid} | post: ${statusLabel} | session: ${sessionStatus}`; }; // Render Welcome Screen (chatty, friendly) const renderWelcomeScreen = () => { const recentSession = availableSessions.length > 0 ? availableSessions[0] : null; return wp.element.createElement( "div", { className: "wpaw-welcome-screen" }, wp.element.createElement( "div", { className: "wpaw-welcome-content" }, wp.element.createElement("span", { className: "wpaw-welcome-icon", dangerouslySetInnerHTML: { __html: '', }, }), wp.element.createElement( "h2", { className: "wpaw-welcome-title" }, "Agentic Writer", ), wp.element.createElement( "p", { className: "wpaw-welcome-subtitle" }, "What are we writing today?", ), // Show single "Continue last conversation" button if available recentSession && wp.element.createElement( "button", { className: "wpaw-welcome-pill", style: { width: "100%", marginBottom: "12px" }, disabled: isSessionActionLoading, onClick: () => openSessionById(recentSession.session_id || ""), }, `↩ Continue: ${getSessionDisplayTitle(recentSession, 0)}`, ), // Show older sessions in collapsible availableSessions.length > 1 && wp.element.createElement( "details", { style: { marginBottom: "12px", width: "100%" }, }, wp.element.createElement( "summary", { style: { fontSize: "12px", color: "#8b95a5", cursor: "pointer", marginBottom: "8px", }, }, `${availableSessions.length - 1} more session${availableSessions.length > 2 ? "s" : ""}`, ), wp.element.createElement( "div", { className: "wpaw-session-list" }, ...availableSessions.slice(1).map((session, idx) => wp.element.createElement( "div", { key: session.session_id || idx, className: "wpaw-welcome-pill", style: { width: "100%", marginBottom: "6px", display: "flex", alignItems: "center", justifyContent: "space-between", gap: "8px", }, }, wp.element.createElement( "button", { type: "button", disabled: isSessionActionLoading, className: "wpaw-session-open-btn", style: { flex: 1, background: "transparent", border: "none", color: "inherit", textAlign: "left", cursor: isSessionActionLoading ? "wait" : "pointer", }, onClick: () => openSessionById(session.session_id || ""), }, wp.element.createElement( "div", null, getSessionDisplayTitle(session, idx + 1), ), wp.element.createElement( "div", { style: { opacity: 0.7, fontSize: "11px" } }, `${Number(session?.message_count ?? (Array.isArray(session?.messages) ? session.messages.length : 0))} msgs · ${getSessionContinuityLabel(session)}`, ), ), wp.element.createElement( "button", { type: "button", title: "Delete session", disabled: isSessionActionLoading, style: { background: "transparent", border: "1px solid rgba(255,255,255,0.25)", color: "inherit", borderRadius: "6px", padding: "2px 6px", cursor: "pointer", }, onClick: () => deleteConversationSession(session.session_id), }, "×", ), ), ), ), ), // Focus keyword input wp.element.createElement("input", { type: "text", className: "wpaw-welcome-input", placeholder: "Focus keyword (optional)", value: welcomeKeywordInput, onChange: (e) => setWelcomeKeywordInput(e.target.value), onKeyDown: (e) => { if (e.key === "Enter") { handleWelcomeStart(); } }, }), // Mode pills wp.element.createElement( "div", { className: "wpaw-welcome-pills" }, wp.element.createElement( "button", { className: "wpaw-welcome-pill" + (welcomeStartMode === "chat" ? " active" : ""), onClick: () => setWelcomeStartMode("chat"), }, "Explore First", ), wp.element.createElement( "button", { className: "wpaw-welcome-pill" + (welcomeStartMode === "planning" ? " active" : ""), onClick: () => setWelcomeStartMode("planning"), }, "Start Outline", ), ), // Start button wp.element.createElement( Button, { isPrimary: true, onClick: handleWelcomeStart, className: "wpaw-welcome-start-btn", }, "Start Writing", ), ), ); }; // 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: '', }, }), wp.element.createElement("h3", null, "Create an Outline First"), wp.element.createElement( "p", null, "Before writing, the agent needs an outline to structure the article and keep costs predictable. Ask for the article topic and the agent will create one first.", ), 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", ), ), wp.element.createElement( "p", { className: "wpaw-empty-state-hint", style: { marginTop: "16px", fontSize: "13px", color: "#a7aaad" }, }, "Tip: tell the agent what you want to publish, then approve or adjust the outline before writing.", ), ), ); }; // Render Focus Keyword Bar (replaces context indicator) const renderFocusKeywordBar = () => { const hasKeyword = selectedFocusKeyword && selectedFocusKeyword.length > 0; // Expanded mode if (isTextareaExpanded) { return wp.element.createElement( "div", { className: "wpaw-focus-keyword-bar wpaw-expanded" }, // Header wp.element.createElement( "div", { className: "wpaw-fk-header" }, wp.element.createElement("span", null, "🎯 FOCUS KEYWORD"), wp.element.createElement( "button", { className: "wpaw-fk-collapse", onClick: () => setIsTextareaExpanded(false), title: "Collapse", }, "↓", ), ), // Main input - always show input field in expanded mode wp.element.createElement( "div", { className: "wpaw-fk-main-input" }, wp.element.createElement("input", { type: "text", className: "wpaw-fk-custom-input", placeholder: hasKeyword ? "Edit focus keyword..." : "Enter focus keyword...", value: selectedFocusKeyword || "", onChange: (e) => { const value = e.target.value; setSelectedFocusKeyword(value); }, onBlur: (e) => { // Save on blur if (e.target.value !== postConfig.focus_keyword) { handleFocusKeywordChange(e.target.value); } }, onKeyDown: (e) => { if (e.key === "Enter" && e.target.value.trim()) { handleFocusKeywordChange(e.target.value.trim()); e.target.blur(); } }, }), ), // Suggestions list focusKeywordSuggestions.length > 0 && wp.element.createElement( "div", { className: "wpaw-fk-suggestions" }, wp.element.createElement( "div", { className: "wpaw-fk-suggestions-label" }, "📝 AI Suggestions:", ), focusKeywordSuggestions.map((kw, i) => wp.element.createElement( "div", { key: i, className: "wpaw-fk-suggestion-item" + (kw === selectedFocusKeyword ? " selected" : ""), onClick: () => handleFocusKeywordChange(kw), }, wp.element.createElement( "span", { className: "wpaw-fk-radio" }, kw === selectedFocusKeyword ? "●" : "○", ), wp.element.createElement( "span", { className: "wpaw-fk-suggestion-text" }, kw, ), wp.element.createElement( "span", { className: "wpaw-fk-suggestion-source" }, `(#${i + 1})`, ), ), ), ), // Stats wp.element.createElement( "div", { className: "wpaw-fk-stats" }, wp.element.createElement( "span", null, `💰 $${(cost.session || 0).toFixed(4)}`, ), providerInfo && wp.element.createElement( "span", { className: "wpaw-provider-info", title: providerInfo.warnings.length > 0 ? providerInfo.warnings.join("; ") : "AI provider used", }, providerInfo.fallbackUsed ? " ⚠️ " + (providerInfo.provider || "fallback") : " 📡 " + (providerInfo.provider || "AI"), ), wp.element.createElement( "span", { className: "wpaw-fk-divider" }, "│", ), wp.element.createElement( "span", null, `📊 ~${messages.filter((m) => m.role !== "system").length * 500} tokens`, ), ), ); } // Compact mode (default) - use input instead of dropdown return wp.element.createElement( "div", { className: "wpaw-focus-keyword-bar wpaw-compact" }, wp.element.createElement( "div", { className: "wpaw-fk-left" }, wp.element.createElement("span", { className: "wpaw-fk-icon" }, "🎯"), wp.element.createElement("input", { type: "text", className: "wpaw-fk-input", placeholder: "Enter focus keyword...", value: selectedFocusKeyword || "", onChange: (e) => { const value = e.target.value; setSelectedFocusKeyword(value); // Debounce save to config if (configSaveTimeoutRef.current) { clearTimeout(configSaveTimeoutRef.current); } configSaveTimeoutRef.current = setTimeout(() => { handleFocusKeywordChange(value); }, 500); }, onBlur: (e) => { // Save immediately on blur if (e.target.value !== postConfig.focus_keyword) { handleFocusKeywordChange(e.target.value); } }, disabled: isLoading, }), ), wp.element.createElement( "span", { className: "wpaw-fk-cost" }, `$${(cost.session || 0).toFixed(4)}`, providerInfo && wp.element.createElement( "span", { className: "wpaw-provider-badge", title: providerInfo.warnings.length > 0 ? providerInfo.warnings.join("; ") : "AI provider", }, providerInfo.fallbackUsed ? "⚠" : "📡", ), ), wp.element.createElement( "button", { className: "wpaw-fk-expand", onClick: () => setIsTextareaExpanded(true), title: "Expand", }, wp.element.createElement( "svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", }, wp.element.createElement("path", { fill: "none", stroke: "currentColor", strokeLinecap: "round", strokeLinejoin: "round", strokeWidth: "1.5", d: "m7 15l5 5l5-5M7 9l5-5l5 5", }), ), ), ); }; const renderAgentWorkspaceCard = () => { const planSummary = getPlanRuntimeSummary(); const messageCount = messages.filter( (m) => m.role === "user" || m.role === "assistant", ).length; const providerLabel = providerInfo?.provider ? `${providerInfo.provider}${providerInfo.model ? ` / ${providerInfo.model}` : ""}` : "Provider not used yet"; const activeWorkspaceStatus = activeOperation.status && activeOperation.status !== "idle" ? activeOperation.status : writingState.status || "idle"; const writingLabel = activeOperation.status && activeOperation.status !== "idle" ? `${activeOperation.status === "stopping" ? "Stopping" : "Running"} ${activeOperation.label || activeOperation.type}` : isWritingStateLoading ? "Loading..." : String(writingState.status || "idle").replace(/_/g, " "); const canResume = !isLoading && currentPlanRef.current && ["in_progress", "paused", "failed"].includes(writingState.status); const selectedPreview = workspaceSnapshot.selectedBlockPreview ? `: ${workspaceSnapshot.selectedBlockPreview}` : ""; return wp.element.createElement( "div", { className: `wpaw-agent-workspace-card${isWorkspaceCollapsed ? " is-collapsed" : ""}`, }, wp.element.createElement( "div", { className: "wpaw-agent-workspace-header" }, wp.element.createElement( "div", { className: "wpaw-agent-workspace-heading" }, wp.element.createElement( "div", { className: "wpaw-agent-workspace-kicker" }, "Agent Workspace", ), wp.element.createElement( "div", { className: "wpaw-agent-workspace-title" }, workspaceSnapshot.title || "Untitled draft", ), ), wp.element.createElement( "div", { className: "wpaw-agent-workspace-actions" }, wp.element.createElement( "span", { className: `wpaw-agent-workspace-status status-${activeWorkspaceStatus}`, }, writingLabel, ), ), ), !isWorkspaceCollapsed && wp.element.createElement( "div", { className: "wpaw-agent-context-grid" }, wp.element.createElement( "div", { className: "wpaw-agent-context-item" }, wp.element.createElement("span", null, "Post blocks"), wp.element.createElement( "strong", null, String(workspaceSnapshot.blockCount || 0), ), ), wp.element.createElement( "div", { className: "wpaw-agent-context-item" }, wp.element.createElement("span", null, "Outline"), wp.element.createElement("strong", null, planSummary.label), ), wp.element.createElement( "div", { className: "wpaw-agent-context-item" }, wp.element.createElement("span", null, "Selected"), wp.element.createElement( "strong", null, `${workspaceSnapshot.selectedBlockLabel}${selectedPreview}`, ), ), wp.element.createElement( "div", { className: "wpaw-agent-context-item" }, wp.element.createElement("span", null, "Focus keyword"), wp.element.createElement("input", { type: "text", className: "wpaw-agent-keyword-input", placeholder: "Optional", value: selectedFocusKeyword || "", onChange: (e) => { const value = e.target.value; setSelectedFocusKeyword(value); if (configSaveTimeoutRef.current) { clearTimeout(configSaveTimeoutRef.current); } configSaveTimeoutRef.current = setTimeout(() => { handleFocusKeywordChange(value); }, 500); }, onBlur: (e) => { if (e.target.value !== postConfig.focus_keyword) { handleFocusKeywordChange(e.target.value); } }, disabled: isLoading, }), ), wp.element.createElement( "div", { className: "wpaw-agent-context-item" }, wp.element.createElement("span", null, "Conversation"), wp.element.createElement( "strong", null, `${messageCount} message${messageCount === 1 ? "" : "s"}`, ), ), wp.element.createElement( "div", { className: "wpaw-agent-context-item" }, wp.element.createElement("span", null, "Provider"), wp.element.createElement("strong", null, providerLabel), ), ), !isWorkspaceCollapsed && canResume && wp.element.createElement( "div", { className: "wpaw-agent-resume-card" }, wp.element.createElement( "div", null, wp.element.createElement( "strong", null, writingState.status === "failed" ? "Writing can be retried" : "Writing can resume", ), wp.element.createElement( "span", null, `Last saved section: ${writingState.current_section_index || 0}`, ), ), wp.element.createElement( Button, { isPrimary: true, isSmall: true, onClick: () => executePlanFromCard({ retry: true, skipConfirm: true }), }, writingState.status === "failed" ? "Retry" : "Resume", ), ), ); }; // Keep old function name for backward compatibility const renderContextIndicator = renderAgentWorkspaceCard; // 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 () => { // Switch to planning mode setAgentMode("planning"); // Get topic from focus keyword or chat history const focusKw = selectedFocusKeyword || postConfig.focus_keyword || postConfig.seo_focus_keyword; const firstUserMsg = messages.find((m) => m.role === "user"); const topic = focusKw || (firstUserMsg ? firstUserMsg.content.substring(0, 100) : ""); // Don't add any user message - directly trigger outline generation setInput(""); setIsLoading(true); // Add timeline entry setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "checking", message: "Analyzing request...", timestamp: new Date(), }, ]); // Call clarity check - MANDATORY before outline generation let requestDetectedLanguage = detectedLanguage; const contextualOperationController = beginAgentOperation( "planning", "outline generation", ); try { wpawLog.log("[WPAW] Calling clarity check with topic:", topic); const clarityResponse = await fetch( wpAgenticWriter.apiUrl + "/check-clarity", { method: "POST", headers: { "Content-Type": "application/json", "X-WP-Nonce": wpAgenticWriter.nonce, }, body: JSON.stringify({ topic: topic || "article outline", answers: [], postId: postId, sessionId: currentSessionId, mode: "generation", postConfig: postConfig, chatHistory: buildChatHistoryPayload(), }), signal: contextualOperationController.signal, }, ); wpawLog.log( "[WPAW] Clarity response status:", clarityResponse.status, ); if (!clarityResponse.ok) { const errorText = await clarityResponse.text(); wpawLog.error("[WPAW] Clarity check failed:", errorText); throw new Error("Clarity check failed: " + errorText); } const clarityData = await clarityResponse.json(); applyProviderMetadata(clarityData); const clarityResult = clarityData.result; wpawLog.log("[WPAW] Clarity result:", clarityResult); if (clarityResult.detected_language) { requestDetectedLanguage = clarityResult.detected_language; setDetectedLanguage(clarityResult.detected_language); } // MANDATORY: Always show quiz if questions exist if ( clarityResult.questions && clarityResult.questions.length > 0 ) { wpawLog.log( "[WPAW] Showing quiz with", clarityResult.questions.length, "questions", ); setQuestions(clarityResult.questions); setInClarification(true); setCurrentQuestionIndex(0); setAnswers([]); setIsLoading(false); setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "waiting", message: "Waiting for clarification...", }; } return newMessages; }); finishAgentOperation("planning"); return; // Stop here - quiz must be completed first } else { wpawLog.warn( "[WPAW] No questions returned from clarity check!", ); } } catch (clarityError) { wpawLog.error("[WPAW] Clarity check error:", clarityError); if ( isAbortError(clarityError) || stopExecutionRef.current || contextualOperationController.signal.aborted ) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Outline generation stopped by user.", timestamp: new Date(), }, ]); } else { // Show error to user instead of silently proceeding setMessages((prev) => [ ...prev, { role: "system", type: "error", content: "Clarity check failed. Please try again.", canRetry: true, }, ]); } setIsLoading(false); finishAgentOperation("planning"); return; // Don't proceed without clarity check } // Proceed with plan generation setMessages((prev) => { const newMessages = [...prev]; const lastTimelineIndex = findLastActiveTimelineIndex(newMessages); if (lastTimelineIndex !== -1) { newMessages[lastTimelineIndex] = { ...newMessages[lastTimelineIndex], status: "starting", message: "Creating outline...", }; } return newMessages; }); 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: topic || "article outline", context: "", postId: postId, sessionId: currentSessionId, answers: [], autoExecute: false, stream: true, articleLength: postConfig.article_length, detectedLanguage: requestDetectedLanguage, postConfig: postConfig, chatHistory: buildChatHistoryPayload(), }), signal: contextualOperationController.signal, }, ); if (!response.ok) { const error = await response.json(); setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( error, "Failed to generate outline", ), canRetry: true, retryType: "generation", }, ]); setIsLoading(false); finishAgentOperation("planning"); return; } // Handle streaming response streamTargetRef.current = null; const reader = registerActiveReader(response.body.getReader()); const decoder = new TextDecoder(); while (true) { if ( stopExecutionRef.current || contextualOperationController.signal.aborted ) { throw new DOMException( "Outline generation stopped", "AbortError", ); } 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((prev) => ({ ...prev, session: prev.session + (data.cost || 0), })); if (data.plan) { updateOrCreatePlanMessage(data.plan, { suggestKeywords: true, }); } } 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; }); } } catch (parseError) { wpawLog.error( "Failed to parse streaming data:", parseError, ); } } } } if ( stopExecutionRef.current || contextualOperationController.signal.aborted ) { throw new DOMException( "Outline generation stopped", "AbortError", ); } setIsLoading(false); finishAgentOperation("planning"); } catch (error) { if ( isAbortError(error) || stopExecutionRef.current || contextualOperationController.signal.aborted ) { setMessages((prev) => [ ...deactivateActiveTimelineEntries(prev), { role: "system", type: "timeline", status: "stopped", message: "Outline generation stopped by user.", timestamp: new Date(), }, ]); } else { setMessages((prev) => [ ...prev, { role: "system", type: "error", content: formatAiErrorMessage( error, "Failed to generate outline", ), canRetry: true, retryType: "generation", }, ]); } setIsLoading(false); finishAgentOperation("planning"); } }, }, }; 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, "'"); }; const inlineMarkdownToHtml = (text) => { let html = escapeHtml(text); html = html.replace( /\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => `${label}`, ); html = html.replace( /`([^`]+)`/g, (match, code) => `${escapeHtml(code)}`, ); html = html.replace(/\*\*([^*]+)\*\*/g, "$1"); html = html.replace(/__([^_]+)__/g, "$1"); html = html.replace(/\*([^*]+)\*/g, "$1"); html = html.replace(/_([^_]+)_/g, "$1"); return html; }; const markdownToHtml = (markdown) => { const raw = normalizeMessageContent(markdown); if (!raw) { return ""; } if (window.markdownit && window.DOMPurify) { if (!markdownRendererRef.current) { const renderer = window.markdownit({ html: false, linkify: true, breaks: false, }); if (window.markdownitTaskLists) { renderer.use(window.markdownitTaskLists, { enabled: true, label: true, labelAfter: true, }); } const defaultLinkOpen = renderer.renderer.rules.link_open || function (tokens, idx, options, env, self) { return self.renderToken(tokens, idx, options); }; renderer.renderer.rules.link_open = function ( tokens, idx, options, env, self, ) { const token = tokens[idx]; const targetIndex = token.attrIndex("target"); if (targetIndex < 0) { token.attrPush(["target", "_blank"]); } else { token.attrs[targetIndex][1] = "_blank"; } const relIndex = token.attrIndex("rel"); if (relIndex < 0) { token.attrPush(["rel", "noopener noreferrer"]); } else { token.attrs[relIndex][1] = "noopener noreferrer"; } return defaultLinkOpen(tokens, idx, options, env, self); }; markdownRendererRef.current = renderer; } const rendered = markdownRendererRef.current.render(raw); return window.DOMPurify.sanitize(rendered, { USE_PROFILES: { html: true }, ADD_TAGS: ["input", "label"], ADD_ATTR: ["type", "checked", "disabled", "class"], }); } const codeBlocks = []; let text = raw.replace( /```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { const safeLang = lang ? ` class="language-${escapeHtml(lang)}"` : ""; const index = codeBlocks.length; codeBlocks.push( `

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

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

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

    ${inlineMarkdownToHtml(detail)}

    `, ) .join("") : ""; const children = item.children && item.children.length > 0 ? `` : ""; return `
  • ${inlineMarkdownToHtml(item.content)}${details}${children}
  • `; }) .join(""); html += `<${list.type}>${items}`; list = null; } }; const addListItem = (targetList, value) => { targetList.items.push({ content: value, children: [], details: [] }); lastLineWasListItem = true; }; const addDetailToLastItem = (targetList, value, newParagraph) => { const lastItem = targetList.items[targetList.items.length - 1]; if (!lastItem) { return; } if (newParagraph || lastItem.details.length === 0) { lastItem.details.push(value); } else { lastItem.details[lastItem.details.length - 1] += ` ${value}`; } lastLineWasListItem = false; }; const getListType = (value) => { if (/^\d+\.\s+/.test(value)) { return "ol"; } if (/^[-*+]\s+/.test(value)) { return "ul"; } return null; }; for (let i = 0; i < lines.length; i++) { const trimmed = lines[i].trim(); if (trimmed === "") { let nextIndex = i + 1; while (nextIndex < lines.length && lines[nextIndex].trim() === "") { nextIndex += 1; } const nextLine = nextIndex < lines.length ? lines[nextIndex].trim() : ""; const nextType = getListType(nextLine); if (list && nextType && nextType === list.type) { continue; } if ( list && list.type === "ol" && nextLine && !nextType && !nextLine.startsWith("@@CODEBLOCK") && !/^(#{1,6})\s+/.test(nextLine) ) { detailBreak = true; lastLineWasListItem = false; continue; } flushList(); flushParagraph(); lastLineWasListItem = false; continue; } if (trimmed.startsWith("@@CODEBLOCK")) { flushList(); flushParagraph(); html += trimmed; lastLineWasListItem = false; continue; } const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/); if (headingMatch) { flushList(); flushParagraph(); const level = headingMatch[1].length; html += `${inlineMarkdownToHtml(headingMatch[2])}`; lastLineWasListItem = false; continue; } const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/); const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/); if (unorderedMatch || orderedMatch) { flushParagraph(); detailBreak = false; const type = orderedMatch ? "ol" : "ul"; let value = (orderedMatch ? orderedMatch[1] : unorderedMatch[1]) || ""; if (orderedMatch) { value = value.replace(/^\d+\.\s+/, ""); } if ( !orderedMatch && list && list.type === "ol" && list.items.length > 0 ) { list.items[list.items.length - 1].children.push(value); continue; } if (!list || list.type !== type) { flushList(); list = { type, items: [] }; } addListItem(list, value); continue; } if ( list && list.type === "ol" && (lastLineWasListItem || detailBreak) ) { addDetailToLastItem(list, trimmed, detailBreak); detailBreak = false; continue; } if (list) { flushList(); } paragraph.push(trimmed); lastLineWasListItem = false; } flushList(); flushParagraph(); codeBlocks.forEach((block, index) => { html = html.replace(`@@CODEBLOCK${index}@@`, block); }); return html; }; const renderMessageContent = (content, allowMarkdown) => { if (!allowMarkdown) { return normalizeMessageContent(content); } return wp.element.createElement(RawHTML, null, markdownToHtml(content)); }; const lastActiveTimelineIndex = findLastActiveTimelineIndex(messages); const groups = []; let currentAiGroup = null; messages.forEach((message, index) => { if (message.role === "user") { groups.push({ type: "user", message, key: `user-${index}` }); currentAiGroup = null; return; } if (!currentAiGroup) { currentAiGroup = { type: "ai", items: [], key: `ai-${index}` }; groups.push(currentAiGroup); } currentAiGroup.items.push({ message, index }); }); return groups.map((group, groupIndex) => { if (group.type === "user") { return wp.element.createElement( "div", { key: group.key, className: "wpaw-message wpaw-message-user", }, wp.element.createElement( "div", { className: "wpaw-message-content" }, renderMessageContent(group.message.content, false), ), ); } const isLastGroup = groupIndex === groups.length - 1; let streamingLabel = "Streaming..."; for (let i = group.items.length - 1; i >= 0; i--) { const item = group.items[i].message; if (item.type === "timeline" && item.status) { if (item.status === "checking") { streamingLabel = "Analyzing..."; } else if ( item.status === "planning" || item.status === "plan_complete" ) { streamingLabel = "Planning..."; } else if ( item.status === "writing" || item.status === "writing_section" ) { streamingLabel = "Writing..."; } else if (item.status === "refining") { streamingLabel = "Refining..."; } else { streamingLabel = "Streaming..."; } break; } } return wp.element.createElement( "div", { key: group.key, className: "wpaw-ai-response", }, group.items.map((item, itemIndex) => { const message = item.message; const index = item.index; const isLastItem = itemIndex === group.items.length - 1; if (message.type === "timeline") { const statusClass = message.status === "complete" ? "complete" : message.status === "inactive" ? "inactive" : "active"; const showProcessing = isLoading && message.status === "refining"; const elapsedTime = message.status === "complete" && message.timestamp && message.completedAt ? ( (new Date(message.completedAt) - new Date(message.timestamp)) / 1000 ).toFixed(1) + "s" : null; return wp.element.createElement( "div", { key: `timeline-${index}`, className: "wpaw-ai-item wpaw-timeline-entry " + statusClass + (index === lastActiveTimelineIndex ? " is-current" : ""), }, wp.element.createElement("div", { className: "wpaw-timeline-dot", "aria-hidden": "true", }), wp.element.createElement( "div", { className: "wpaw-timeline-content" }, wp.element.createElement( "div", { className: "wpaw-timeline-message" }, normalizeMessageContent(message.message), ), message.status === "complete" && wp.element.createElement( "div", { className: "wpaw-timeline-complete-row", style: { display: "flex", justifyContent: "space-between", alignItems: "center", }, }, wp.element.createElement( "div", { className: "wpaw-timeline-complete" }, "✓ Complete", elapsedTime && wp.element.createElement( "span", { className: "wpaw-timeline-elapsed" }, ` (${elapsedTime})`, ), ), aiUndoStack.length > 0 && isLastGroup && isLastItem && (message.message.toLowerCase().includes("refine") || message.message .toLowerCase() .includes("generated")) && wp.element.createElement( "button", { className: "wpaw-inline-undo-btn", onClick: undoLastAiOperation, disabled: isLoading, title: `Undo: ${aiUndoStack[aiUndoStack.length - 1]?.label || "Last AI operation"}`, style: { background: "transparent", border: "none", color: "var(--aw-primary)", cursor: isLoading ? "not-allowed" : "pointer", fontSize: "11px", fontFamily: "inherit", fontWeight: "500", display: "flex", alignItems: "center", gap: "4px", padding: "2px 6px", borderRadius: "4px", }, }, wp.element.createElement("span", { dangerouslySetInnerHTML: { __html: '', }, }), "Undo", ), ), 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-typing-indicator", "aria-label": "Agent is typing", }, streamingLabel, wp.element.createElement( "span", { className: "wpaw-typing-dots" }, wp.element.createElement("span", null), wp.element.createElement("span", null), wp.element.createElement("span", null), ), ), ), ); } 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", style: { marginBottom: "6px" }, }, item.title, ), item.viewInEditor && wp.element.createElement( "button", { type: "button", className: "wpaw-edit-plan-item-target", disabled: !isPlanActive, title: "Scroll to changes in editor", 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", }); } }, }, "View in Editor 👁️", ), ), ), ), 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(); }; // Support structured error objects { title, detail, actionUrl, actionLabel } const errContent = message.content; const isStructured = errContent && typeof errContent === "object" && errContent.title; return wp.element.createElement( "div", { key: `error-${index}`, className: "wpaw-ai-item wpaw-message wpaw-message-error", }, isStructured ? wp.element.createElement( "div", null, wp.element.createElement( "div", { className: "wpaw-error-title" }, "⚠ ", errContent.title, ), errContent.detail && wp.element.createElement( "div", { className: "wpaw-error-detail" }, errContent.detail, ), errContent.actionUrl && wp.element.createElement( "a", { href: errContent.actionUrl, target: "_blank", rel: "noopener", style: { display: "inline-block", marginTop: "8px", fontSize: "12px", color: "#fca5a5", textDecoration: "underline", }, }, errContent.actionLabel || "Open Settings", ), ) : wp.element.createElement( "div", { className: "wpaw-message-content" }, renderMessageContent(errContent, 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-typing-indicator", "aria-label": "Agent is typing", }, streamingLabel, wp.element.createElement( "span", { className: "wpaw-typing-dots" }, wp.element.createElement("span", null), wp.element.createElement("span", null), wp.element.createElement("span", null), ), ), 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"); }, }, "Review Outline", ), ), ); }), ); }); }; // 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("h3", null, "CONFIGURATION"), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement("label", null, "AGENT WORKSPACE"), wp.element.createElement( "p", { className: "description" }, "The command box now routes chat, outline, writing, refinement, and SEO requests automatically from the current editor context.", ), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement("label", null, "ARTICLE LENGTH"), wp.element.createElement( "select", { value: postConfig.article_length, onChange: (e) => updatePostConfig("article_length", e.target.value), disabled: isConfigDisabled, className: "wpaw-select", }, wp.element.createElement( "option", { value: "short" }, "Short (500-800 words)", ), wp.element.createElement( "option", { value: "medium" }, "Medium (800-1500 words)", ), wp.element.createElement( "option", { value: "long" }, "Long (1500-2500 words)", ), ), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement("label", null, "Language"), wp.element.createElement( "select", { value: postConfig.language, onChange: (e) => updatePostConfig("language", e.target.value), disabled: isConfigDisabled, className: "wpaw-select", }, (() => { const preferredLanguages = settings.preferred_languages || [ "auto", "English", "Indonesian", ]; const customLanguages = settings.custom_languages || []; const allLanguages = [...preferredLanguages, ...customLanguages]; return allLanguages.map((lang) => { const langLower = lang.toLowerCase(); const displayName = lang === "auto" ? "Auto-detect" : lang; return wp.element.createElement( "option", { key: langLower, value: langLower }, displayName, ); }); })(), ), wp.element.createElement( "p", { className: "description" }, "Overrides the detected language when writing or refining.", ), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement(TextControl, { label: "Tone", value: postConfig.tone, onChange: (value) => updatePostConfig("tone", value), disabled: isConfigDisabled, placeholder: "e.g., Friendly, persuasive, professional", }), wp.element.createElement( "p", { className: "description" }, "Use this to consistently guide the writing tone.", ), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement("label", null, "Experience Level"), wp.element.createElement( "select", { value: postConfig.experience_level, onChange: (e) => updatePostConfig("experience_level", e.target.value), disabled: isConfigDisabled, className: "wpaw-select", }, wp.element.createElement( "option", { value: "general" }, "General audience", ), wp.element.createElement( "option", { value: "beginner" }, "Beginner", ), wp.element.createElement( "option", { value: "intermediate" }, "Intermediate", ), wp.element.createElement( "option", { value: "advanced" }, "Advanced", ), ), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement(CheckboxControl, { label: "Include image suggestions", checked: Boolean(postConfig.include_images), onChange: (value) => updatePostConfig("include_images", value), disabled: isConfigDisabled, }), wp.element.createElement( "p", { className: "description" }, "When enabled, the agent will add image placeholders.", ), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement(CheckboxControl, { label: "Enable web search for outlines", checked: Boolean(postConfig.web_search), onChange: (value) => updatePostConfig("web_search", value), disabled: isConfigDisabled, }), wp.element.createElement( "p", { className: "description" }, "Uses web search when planning outlines.", ), ), // SEO Section wp.element.createElement( "div", { className: "wpaw-config-divider" }, wp.element.createElement("span", null, "🔍 SEO OPTIMIZATION"), ), wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement(CheckboxControl, { label: "Enable SEO optimization", checked: Boolean(postConfig.seo_enabled), onChange: (value) => updatePostConfig("seo_enabled", value), disabled: isConfigDisabled, }), wp.element.createElement( "p", { className: "description" }, "Include SEO guidelines in AI prompts for keyword-optimized content.", ), ), postConfig.seo_enabled && wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement(TextControl, { label: "Focus Keyword", value: postConfig.seo_focus_keyword, onChange: (value) => updatePostConfig("seo_focus_keyword", value), disabled: isConfigDisabled, placeholder: "e.g., wordpress seo plugin", }), wp.element.createElement( "p", { className: "description" }, "Primary keyword to optimize content for. Will be included in title, headings, and body.", ), ), postConfig.seo_enabled && wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement(TextControl, { label: "Secondary Keywords", value: postConfig.seo_secondary_keywords, onChange: (value) => updatePostConfig("seo_secondary_keywords", value), disabled: isConfigDisabled, placeholder: "e.g., content optimization, search ranking", }), wp.element.createElement( "p", { className: "description" }, "Comma-separated related keywords to sprinkle throughout content.", ), ), postConfig.seo_enabled && wp.element.createElement( "div", { className: "wpaw-config-section" }, wp.element.createElement(TextareaControl, { label: "Meta Description", value: postConfig.seo_meta_description, onChange: (value) => updatePostConfig("seo_meta_description", value), disabled: isConfigDisabled, placeholder: "Enter meta description (120-160 chars recommended)", rows: 3, }), wp.element.createElement( "div", { className: "wpaw-meta-info" }, wp.element.createElement( "span", { className: (postConfig.seo_meta_description?.length || 0) >= 120 && (postConfig.seo_meta_description?.length || 0) <= 160 ? "good" : "warning", }, `${postConfig.seo_meta_description?.length || 0}/160 chars`, ), wp.element.createElement( Button, { isSecondary: true, isSmall: true, onClick: () => generateMetaDescription(), disabled: isConfigDisabled || isGeneratingMeta, }, isGeneratingMeta ? wp.element.createElement( "span", { style: { display: "flex", alignItems: "center", gap: "5px", }, }, wp.element.createElement("span", { className: "wpaw-spinning-icon", dangerouslySetInnerHTML: { __html: '', }, }), " Generating...", ) : wp.element.createElement( "span", { style: { display: "flex", alignItems: "center", gap: "5px", }, }, wp.element.createElement("span", { className: "wpaw-svg-wrapper", dangerouslySetInnerHTML: { __html: '', }, }), " Generate", ), ), ), ), // SEO Audit Section postConfig.seo_enabled && wp.element.createElement( "div", { className: "wpaw-config-section wpaw-seo-audit" }, wp.element.createElement( "div", { className: "wpaw-seo-audit-header" }, wp.element.createElement("label", null, "SEO Audit"), wp.element.createElement( Button, { isSecondary: true, isSmall: true, onClick: () => runSeoAudit(), disabled: isConfigDisabled || isSeoAuditing, }, isSeoAuditing ? wp.element.createElement( "span", { style: { display: "flex", alignItems: "center", gap: "5px", }, }, wp.element.createElement("span", { className: "wpaw-spinning-icon", style: { display: "inline-flex", lineHeight: "0" }, dangerouslySetInnerHTML: { // Icon Loader/Circle-slashed untuk kesan analyzing __html: '', }, }), " Analyzing...", ) : wp.element.createElement( "span", { style: { display: "flex", alignItems: "center", gap: "5px", }, }, wp.element.createElement("span", { className: "wpaw-svg-wrapper", style: { display: "inline-flex", lineHeight: "0" }, dangerouslySetInnerHTML: { // Icon Bar-Chart untuk "Run Audit" __html: '', }, }), " Run Audit", ), ), ), seoAudit && wp.element.createElement( "div", { className: "wpaw-seo-audit-results" }, wp.element.createElement( "div", { className: "wpaw-seo-score " + (seoAudit.score >= 70 ? "good" : seoAudit.score >= 40 ? "warning" : "poor"), }, wp.element.createElement( "span", { className: "score-value" }, seoAudit.score, ), wp.element.createElement( "span", { className: "score-label" }, "/100", ), ), wp.element.createElement( "div", { className: "wpaw-seo-stats" }, wp.element.createElement( "div", { className: "wpaw-seo-stat" }, wp.element.createElement( "span", { className: "stat-label" }, "Words", ), wp.element.createElement( "span", { className: "stat-value" }, seoAudit.word_count || 0, ), ), wp.element.createElement( "div", { className: "wpaw-seo-stat" }, wp.element.createElement( "span", { className: "stat-label" }, "Keyword Density", ), wp.element.createElement( "span", { className: "stat-value" }, `${(seoAudit.keyword_density || 0).toFixed(1)}%`, ), ), ), seoAudit.checks && wp.element.createElement( "div", { className: "wpaw-seo-checks" }, seoAudit.checks.map((check, idx) => { const isPassed = check.status === "good" || check.status === "ok"; const isFixing = activeSeoFixKey === getSeoFixKey(check); 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, ), !isPassed && wp.element.createElement( Button, { isSmall: true, isSecondary: true, className: "wpaw-seo-fix-button" + (isFixing ? " is-fixing" : ""), onClick: () => handleSeoAuditFix(check), disabled: isLoading || isSeoAuditing || isGeneratingMeta || Boolean(activeSeoFixKey), }, isFixing ? "Fixing..." : "Fix", ), ); }), ), ), !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", ), ), ), ); }; const getAgentStatus = () => { const agentBusy = isLoading || isSeoAuditing || isGeneratingMeta; const isStoppingOperation = activeOperation.status === "stopping"; if (isStoppingOperation) return "stopping"; if (!agentBusy) return "idle"; const lastMsg = messages.filter((m) => m.type === "timeline").pop(); if (activeOperation.type === "refinement") return "refining"; if (activeOperation.type === "seo_audit") return "checking"; if (lastMsg?.message?.toLowerCase().includes("writing")) return "writing"; if (lastMsg?.message?.toLowerCase().includes("generating")) return "writing"; return "thinking"; }; const renderGlobalStatusBar = () => { const agentStatus = getAgentStatus(); const statusLabels = { idle: "Ready", thinking: "Thinking...", checking: "Checking...", refining: "Refining...", writing: "Writing...", stopping: "Stopping...", complete: "Done", error: "Error", }; return wp.element.createElement( "div", { className: "wpaw-status-bar", role: "status", "aria-live": "polite", }, 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], ), ), // MEMANTO: Restored from memory badge memantoRestore.restored && wp.element.createElement( "div", { className: "wpaw-memanto-badge", title: "Restored from memory: " + (memantoRestore.summary || "prior session context"), }, "\uD83E\uDDE0 Restored", ), 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 has-undo", title: `Undo: ${aiUndoStack[aiUndoStack.length - 1]?.label || "Last AI operation"}`, onClick: undoLastAiOperation, disabled: isLoading, dangerouslySetInnerHTML: { __html: '', }, }), // Sessions Icon Button (list/archive -> opens welcome screen) wp.element.createElement("button", { className: "wpaw-status-icon-btn" + (activeTab === "chat" && showWelcome ? " is-active" : ""), dangerouslySetInnerHTML: { __html: '', }, title: "Sessions", onClick: () => { setActiveTab("chat"); setShowWelcome(true); }, disabled: isLoading, }), // Chat Icon Button (speech bubble -> jumps back to active conversation) messages.length > 0 && wp.element.createElement("button", { className: "wpaw-status-icon-btn" + (activeTab === "chat" && !showWelcome ? " is-active" : ""), dangerouslySetInnerHTML: { __html: '', }, title: "Chat", onClick: () => { setActiveTab("chat"); setShowWelcome(false); }, disabled: isLoading, }), // Agent Workspace Icon Toggle messages.length > 0 && wp.element.createElement("button", { className: "wpaw-status-icon-btn wpaw-workspace-toggle-btn", dangerouslySetInnerHTML: { __html: '', }, title: isWorkspaceCollapsed ? "Show Agent Workspace" : "Hide Agent Workspace", onClick: toggleAgentWorkspace, }), // Config Icon Button (gear → opens config tab) wp.element.createElement("button", { className: "wpaw-status-icon-btn" + (activeTab === "config" ? " is-active" : ""), dangerouslySetInnerHTML: { __html: '', }, title: "Configuration", onClick: () => setActiveTab(activeTab === "config" ? "chat" : "config"), disabled: isLoading, }), // Cost Icon Button wp.element.createElement("button", { className: "wpaw-status-icon-btn" + (activeTab === "cost" ? " is-active" : ""), dangerouslySetInnerHTML: { __html: '', }, title: "Cost Tracking", onClick: () => setActiveTab(activeTab === "cost" ? "chat" : "cost"), disabled: isLoading, }), ), ); }; // Render Chat Tab const renderChatTab = () => { const agentBusy = isLoading || isSeoAuditing || isGeneratingMeta; const isStoppingOperation = activeOperation.status === "stopping"; return wp.element.createElement( "div", { className: "wpaw-tab-content wpaw-chat-tab dark-theme" }, wp.element.createElement( "div", { className: `wpaw-chat-container ${inClarification ? "is-dimmed" : ""}`, }, // Editor Lock Banner isEditorLocked && wp.element.createElement( "div", { className: "wpaw-editor-lock-banner" }, "Writing in progress — please wait until the article finishes.", ), isRefinementLocked && wp.element.createElement( "div", { className: "wpaw-refinement-lock-banner" }, `Refining in progress — editing is temporarily locked. You can still scroll and review changes live (${refiningBlockIds.length} target block(s)).`, ), // Session lock notice (another tab is editing this session) sessionLock.lockedByOther && wp.element.createElement( "div", { className: "wpaw-session-lock-banner" }, wp.element.createElement( "span", null, "\u26A0\uFE0F This session is active in another tab. Changes here won\u2019t be saved.", ), wp.element.createElement( Button, { isSmall: true, isSecondary: true, className: "wpaw-session-lock-takeover", onClick: takeOverSession, }, "Take Over", ), ), // Health Check Warnings wpAgenticWriter.health && !wpAgenticWriter.health.ok && wpAgenticWriter.health.issues.map((issue, idx) => wp.element.createElement( "div", { key: `health-${idx}`, className: "wpaw-health-notice" }, "⚠️ ", issue.message, issue.actionUrl && wp.element.createElement( "a", { href: issue.actionUrl, target: "_blank", rel: "noopener", style: { marginLeft: "8px" }, }, issue.actionLabel || "Fix", ), ), ), // Welcome Screen (first time) showWelcome && !isEditorLocked && renderWelcomeScreen(), // Writing Mode Empty State !showWelcome && shouldShowWritingEmptyState() && renderWritingEmptyState(), // Agent Workspace Context !showWelcome && !shouldShowWritingEmptyState() && renderContextIndicator(), // Activity Log !showWelcome && !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 }), ), ), // Command Input Area - hide when showing empty state or welcome !showWelcome && !shouldShowWritingEmptyState() && wp.element.createElement( "div", { className: "wpaw-command-area", style: { position: "relative" }, }, renderClarification(), // Slash command hint with animation classes wp.element.createElement( "div", { className: `wpaw-input-hint ${input || isLoading ? "is-hidden" : ""}`, }, "Type ", wp.element.createElement("kbd", null, "/"), " for commands or ", wp.element.createElement("kbd", null, "@"), " to mention a block", ), // 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("textarea", { ref: inputRef, className: "wpaw-input", value: input, disabled: sessionLock.lockedByOther, onChange: (e) => { const val = e.target.value; handleInputChange(val); const el = inputRef.current; if (el) { el.style.height = "auto"; el.style.height = Math.min(el.scrollHeight, 250) + "px"; el.style.overflowY = el.scrollHeight > 250 ? "auto" : "hidden"; } }, onKeyDown: handleKeyDown, rows: 2, style: { minHeight: "60px", maxHeight: "250px", resize: "none", width: "100%", boxSizing: "border-box", padding: "10px 12px", fontFamily: "inherit", fontSize: "13px", lineHeight: "1.4", border: "1px solid var(--aw-outline-subtle)", borderRadius: "6px", background: "transparent", color: "inherit", }, placeholder: "Ask the agent to write, continue, inspect, refine, or use @ to target blocks...", }), ), 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" }, (() => { // Determine if web search is available for the current provider const taskProviders = settings.task_providers || {}; const currentProvider = taskProviders[agentMode] || "openrouter"; const isNonOpenRouter = currentProvider === "local_backend" || currentProvider === "codex"; const hasBraveKey = Boolean(settings.brave_search_api_key); const searchBlocked = isNonOpenRouter && !hasBraveKey; const tooltipText = searchBlocked ? "Web Search unavailable — Brave API Key required for " + currentProvider.replace("_", " ") + ". Configure in Settings > General." : isNonOpenRouter ? "Web search via Brave Search API (free tier: 2,000 req/mo)" : "Web search via OpenRouter (~$0.02/search)"; return wp.element.createElement( "label", { className: "wpaw-web-search-toggle" + (searchBlocked ? " wpaw-search-blocked" : ""), title: tooltipText, onClick: searchBlocked ? (e) => { e.preventDefault(); alert( "Web Search for " + currentProvider.replace("_", " ") + " requires a Brave Search API Key.\n\nGet a free key (2,000 requests/month) and configure it in:\nWP Agentic Writer Settings → General → Brave Search API Key", ); } : undefined, }, wp.element.createElement("input", { type: "checkbox", checked: searchBlocked ? false : postConfig.web_search || false, onChange: searchBlocked ? () => {} : (e) => { updatePostConfig("web_search", e.target.checked); }, disabled: isLoading || searchBlocked, }), wp.element.createElement("span", { className: "wpaw-web-search-icon", dangerouslySetInnerHTML: { __html: '', }, }), wp.element.createElement( "span", { className: "wpaw-web-search-label" }, searchBlocked ? "Search ✕" : "Search", ), ); })(), ), wp.element.createElement( "div", { className: "wpaw-command-actions-group" }, // Stop Button (appears during execution) - Circle with pause icon agentBusy && wp.element.createElement("button", { className: "wpaw-command-circle-btn wpaw-stop-circle-btn" + (isStoppingOperation ? " is-stopping" : ""), type: "button", onClick: handleStopExecution, disabled: isStoppingOperation, title: isStoppingOperation ? "Stopping..." : "Stop current operation", dangerouslySetInnerHTML: { __html: isStoppingOperation ? '' : '', }, }), // Send Button (Bottom Right) - Circle with send icon !agentBusy && wp.element.createElement("button", { className: "wpaw-command-circle-btn wpaw-send-circle-btn", type: "button", onClick: sendMessage, disabled: !input.trim() || sessionLock.lockedByOther, title: "Send message", dangerouslySetInnerHTML: { __html: '', }, }), ), ), wp.element.createElement( "div", { className: "wpaw-keyboard-hints", "aria-hidden": "true" }, wp.element.createElement( "span", { className: "wpaw-kbd" }, wp.element.createElement( "kbd", null, /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? "⌘" : "Ctrl", ), "+", wp.element.createElement("kbd", null, "↵"), " Send", ), wp.element.createElement( "span", { className: "wpaw-kbd" }, wp.element.createElement("kbd", null, "@"), " Blocks", ), wp.element.createElement( "span", { className: "wpaw-kbd" }, wp.element.createElement("kbd", null, "/"), " Commands", ), ), renderRefineAllConfirmModal(), ), ), ); }; // 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) { wpawLog.error("Failed to refresh cost data:", e); } }; React.useEffect(() => { if (activeTab === "cost") { refreshCostData(); } }, [activeTab, postId]); // 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); const actionSummary = costHistory.reduce((acc, curr) => { const action = curr.action || "unknown"; const costVal = parseFloat(curr.cost || 0); const tokens = parseInt(curr.input_tokens || 0) + parseInt(curr.output_tokens || 0); if (!acc[action]) acc[action] = { action, cost: 0, tokens: 0, count: 0 }; acc[action].cost += costVal; acc[action].tokens += tokens; acc[action].count += 1; return acc; }, {}); const summaryData = Object.values(actionSummary).sort( (a, b) => b.cost - a.cost, ); 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("h3", null, "OPENROUTER COST"), wp.element.createElement("button", { className: "wpaw-refresh-btn", dangerouslySetInnerHTML: { __html: '', }, onClick: refreshCostData, title: "Refresh cost data", }), ), wp.element.createElement( "div", { className: "wpaw-cost-card" }, wp.element.createElement( "div", { className: "wpaw-cost-stat" }, wp.element.createElement("label", null, "This Post"), wp.element.createElement( "div", { className: "wpaw-cost-value" }, "$", cost.session.toFixed(4), ), ), wp.element.createElement( "div", { className: "wpaw-cost-stat" }, wp.element.createElement("label", null, "Month Used"), wp.element.createElement( "div", { className: "wpaw-cost-value" }, "$", cost.monthlyUsed.toFixed(4), ), ), wp.element.createElement( "div", { className: "wpaw-cost-stat wpaw-cost-remaining" }, wp.element.createElement("label", null, "Remaining"), wp.element.createElement( "div", { className: "wpaw-cost-value " + budgetStatus }, "$", remaining.toFixed(2), ), ), ), wp.element.createElement( "div", { className: "wpaw-budget-section" }, wp.element.createElement( "div", { className: "wpaw-budget-label" }, wp.element.createElement( "span", null, "Budget: $", monthlyBudget.toFixed(2), ), wp.element.createElement( "span", null, budgetPercent.toFixed(1), "%", ), ), wp.element.createElement( "div", { className: "wpaw-budget-bar" }, wp.element.createElement("div", { className: "wpaw-budget-fill " + budgetStatus, style: { width: Math.min(budgetPercent, 100) + "%" }, }), ), ), budgetPercent > 80 && wp.element.createElement( "div", { className: "wpaw-budget-warning " + budgetStatus, }, budgetPercent >= 100 ? "⚠️ Budget exceeded!" : "⚠️ Approaching budget limit", ), costHistory.length > 0 && wp.element.createElement( "div", { className: "wpaw-cost-history" }, wp.element.createElement("h4", null, "Cost By Action"), wp.element.createElement( "div", { className: "wpaw-cost-table-wrapper", style: { marginBottom: "24px" }, }, wp.element.createElement( "table", { className: "wpaw-cost-table" }, wp.element.createElement( "thead", null, wp.element.createElement( "tr", null, wp.element.createElement("th", null, "Action"), wp.element.createElement("th", null, "Calls"), wp.element.createElement("th", null, "Tokens"), wp.element.createElement("th", null, "Cost(US$)"), ), ), wp.element.createElement( "tbody", null, summaryData.map((stat, idx) => wp.element.createElement( "tr", { key: idx }, wp.element.createElement("td", null, stat.action), wp.element.createElement("td", null, stat.count), wp.element.createElement( "td", null, stat.tokens.toLocaleString(), ), wp.element.createElement( "td", null, "$" + stat.cost.toFixed(4), ), ), ), ), ), ), wp.element.createElement("h4", null, "OpenRouter 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(US$)"), ), ), wp.element.createElement( "tbody", null, costHistory.map((record, idx) => { const totalTokens = parseInt(record.input_tokens || 0) + parseInt(record.output_tokens || 0); const time = new Date(record.created_at).toLocaleTimeString( "en-US", { hour: "2-digit", minute: "2-digit" }, ); const modelShort = record.model ? record.model.split("/").pop().substring(0, 20) : "N/A"; return wp.element.createElement( "tr", { key: idx }, wp.element.createElement("td", null, time), wp.element.createElement("td", null, record.action), wp.element.createElement( "td", { title: record.model }, modelShort, ), wp.element.createElement( "td", null, totalTokens.toLocaleString(), ), wp.element.createElement( "td", null, "$" + parseFloat(record.cost).toFixed(4), ), ); }), ), ), ), ), wp.element.createElement( "div", { className: "wpaw-cost-footer" }, wp.element.createElement( "a", { href: settings.settings_url || "/wp-admin/options-general.php?page=wp-agentic-writer", target: "_blank", className: "wpaw-cost-settings-link", }, wp.element.createElement("span", { dangerouslySetInnerHTML: { __html: ' Manage Budget Settings', }, }), ), ), ); }; // Main render. return wp.element.createElement( wp.element.Fragment, null, wp.element.createElement( PluginSidebarMoreMenuItem, { target: "wp-agentic-writer", icon: pluginIcon, }, "WP Agentic Writer", ), wp.element.createElement( PluginSidebar, { name: "wp-agentic-writer", title: wp.element.createElement( "div", { style: { display: "flex", alignItems: "center", gap: "8px" } }, wp.element.createElement("img", { 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" }, renderGlobalStatusBar(), activeTab === "chat" && renderChatTab(), activeTab === "config" && renderConfigTab(), activeTab === "cost" && renderCostTab(), ), ), ), ); }; // HOC to get post ID. const mapSelectToProps = (select) => ({ postId: select("core/editor").getCurrentPostId(), }); // Connect sidebar to Redux store. const ConnectedSidebar = wp.data.withSelect(mapSelectToProps)(AgenticWriterSidebar); // Register plugin. registerPlugin("wp-agentic-writer", { icon: pluginIcon, render: ConnectedSidebar, }); })(window.wp);