Files
wp-agentic-writer/assets/js/sidebar.js
Dwindi Ramadhana 619d36d3c8 feat: MEMANTO integration — persistent memory for cross-session context (Phases 1-4)
Phase 1: Core Client
- New class-memanto-client.php: Singleton PHP client for MEMANTO API v2
  - Health check with 5-min transient caching
  - Agent CRUD (ensure, activate, deactivate sessions)
  - Memory operations (remember, batch_remember, recall, recall_recent)
  - Auto re-activation on expired session tokens (401 retry)

Phase 2: Write-Through Memory Hooks
- New class-memanto-context-enhancer.php: Orchestrates remember/recall
  - Fires on: user message, plan generated/approved/rejected,
    section written, block refined, config saved, session start/end
  - All hooks via do_action() — zero coupling to MEMANTO when disabled

Phase 3: Context Enrichment
- Context builder injects recalled memories into AI prompts
  via build_memanto_context() in build_working_context()
- 3-recall strategy: recent post memories, semantic search, user preferences
- Deduplication by content hash

Phase 4: Cross-Session Restore
- New REST endpoints: /memanto/restore, /memanto/preferences
- restore_session() recalls 15 recent memories + user preferences on editor load
- build_session_restore_message() creates AI-ready system message
- get_user_preferences_for_new_post() extracts tone/audience/length/language
- Frontend: 🧠 Restored badge in status bar with memory count tooltip
- Preference carry-over: auto-fills post config from stored user preferences
- deactivate_session() called on session end (triggers MEMANTO summary)
- Badge clears on new conversation start

Settings UI:
- MEMANTO Context Keeper section with enable toggle, URL, API key, test connection
- Settings registered via class-settings-v2.php + tab-memanto.php view

Graceful degradation: all MEMANTO calls guarded by is_active(),
frontend catches silently, plugin works identically when disabled.
2026-06-08 12:42:04 +07:00

11793 lines
406 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WP Agentic Writer - Gutenberg Sidebar
*
* @package WP_Agentic_Writer
*/
(function (wp) {
const { registerPlugin } = wp.plugins;
const { 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
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");
// 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 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 (sessionId, items) => {
if (!sessionId) {
return;
}
const sanitized = sanitizeMessagesForStorage(items);
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],
);
// 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;
}
const sanitized = sanitizeMessagesForStorage(messages);
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 (e) {
// Best effort - ignore errors during unload
}
};
window.addEventListener("beforeunload", flushOnUnload);
window.addEventListener("pagehide", flushOnUnload);
return () => {
window.removeEventListener("beforeunload", flushOnUnload);
window.removeEventListener("pagehide", flushOnUnload);
};
}, [currentSessionId, messages, sanitizeMessagesForStorage]);
React.useEffect(() => {
if (!currentSessionId) {
return;
}
if (isHydratingSessionRef.current) {
return;
}
if (messagesSaveTimeoutRef.current) {
clearTimeout(messagesSaveTimeoutRef.current);
}
messagesSaveTimeoutRef.current = setTimeout(() => {
persistSessionMessages(currentSessionId, messages);
}, 700);
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;
}
}
}
// 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);
}
} 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 {
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();
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
}
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>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/paragraph", {
content: content,
});
} else if (data.block.blockName === "core/heading") {
const level = data.block.attrs?.level || 2;
const content =
data.block.innerHTML?.match(
/<h[1-6]>(.*?)<\/h[1-6]>/,
)?.[1] || "";
newBlock = wp.blocks.createBlock("core/heading", {
level: level,
content: content,
});
} else if (data.block.blockName === "core/list") {
const listItems = (data.block.innerBlocks || []).map(
(item) => {
const content =
item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || "";
return wp.blocks.createBlock("core/list-item", {
content: content,
});
},
);
newBlock = wp.blocks.createBlock(
"core/list",
{
...(data.block.attrs || {}),
ordered: data.block.attrs?.ordered || false,
},
listItems,
);
} else if (data.block.blockName === "core/quote") {
const content =
data.block.innerHTML?.match(/<p>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/quote", {
value: content,
});
} else if (data.block.blockName === "core/image") {
newBlock = wp.blocks.createBlock(
"core/image",
data.block.attrs || {},
);
} else if (data.block.blockName === "core/code") {
newBlock = wp.blocks.createBlock(
"core/code",
data.block.attrs || {},
);
}
if (newBlock) {
insertBlocks(newBlock);
}
} else if (data.type === "complete") {
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",
});
}
return wp.blocks.createBlock(blockType, { content: content });
};
const normalizePlanActions = (plan) => {
if (!plan || !plan.actions) {
return [];
}
if (Array.isArray(plan.actions)) {
return plan.actions;
}
return Object.values(plan.actions);
};
const buildPlanPreviewItem = (action, index) => {
if (!action || !action.action) {
return { title: "Unknown action" };
}
const type = action.blockType
? ` (${action.blockType.replace("core/", "")})`
: "";
const content = (action.content || "").replace(/\s+/g, " ").trim();
const contentPreview = content
? `"${content.substring(0, 80)}${content.length > 80 ? "..." : ""}"`
: "";
const before = getBlockPreviewById(action.blockId);
const beforePreview = before
? `"${before.substring(0, 80)}${before.length > 80 ? "..." : ""}"`
: "";
const targetLabel = before
? ` "${before.substring(0, 40)}${before.length > 40 ? "..." : ""}"`
: "";
const targetPreview = beforePreview || '"Target block not found"';
const blockId = action.blockId || null;
switch (action.action) {
case "keep":
return { title: "Keep" };
case "delete":
return {
title: `Delete${targetLabel}`,
target: targetPreview,
targetLabel: "Target",
blockId,
};
case "replace":
return {
title: `Replace${targetLabel}${type}`,
before: beforePreview,
after: contentPreview,
blockId,
};
case "change_type":
return {
title: `Change type${targetLabel}${type}`,
before: beforePreview,
after: contentPreview,
blockId,
};
case "insert_before":
return {
title: `Insert before${targetLabel}${type}`,
target: targetPreview,
targetLabel: "Target",
after: contentPreview,
blockId,
};
case "insert_after":
return {
title: `Insert after${targetLabel}${type}`,
target: targetPreview,
targetLabel: "Target",
after: contentPreview,
blockId,
};
default:
return {
title: `${action.action}${targetLabel}${type}`,
after: contentPreview,
blockId,
};
}
};
const normalizePlanSectionTitle = (section) => {
const heading = (section?.heading || section?.title || "").toString();
return heading
.replace(/<[^>]+>/g, "")
.trim()
.toLowerCase();
};
const upsertSectionBlock = (sectionId, blockId) => {
if (!sectionId || !blockId) {
return;
}
const sectionMap = sectionBlocksRef.current[sectionId] || [];
if (!sectionMap.includes(blockId)) {
sectionBlocksRef.current[sectionId] = [...sectionMap, blockId];
}
blockSectionRef.current[blockId] = sectionId;
};
const removeSectionBlock = (sectionId, blockId) => {
if (!sectionId || !blockId) {
return;
}
const sectionMap = sectionBlocksRef.current[sectionId] || [];
sectionBlocksRef.current[sectionId] = sectionMap.filter(
(id) => id !== blockId,
);
delete blockSectionRef.current[blockId];
};
const loadSectionBlocks = async () => {
if (!postId) {
return;
}
try {
const response = await fetch(
`${wpAgenticWriter.apiUrl}/section-blocks/${postId}`,
{
method: "GET",
headers: {
"X-WP-Nonce": wpAgenticWriter.nonce,
},
},
);
if (!response.ok) {
return;
}
const data = await response.json();
if (
data &&
data.sectionBlocks &&
typeof data.sectionBlocks === "object"
) {
sectionBlocksRef.current = data.sectionBlocks;
blockSectionRef.current = {};
Object.entries(data.sectionBlocks).forEach(
([sectionId, blockIds]) => {
if (Array.isArray(blockIds)) {
blockIds.forEach((blockId) => {
blockSectionRef.current[blockId] = sectionId;
});
}
},
);
}
} catch (error) {
// Ignore load failures for section mapping.
}
};
const saveSectionBlocks = async (sectionId) => {
if (!sectionId || !postId) {
return;
}
const blockIds = sectionBlocksRef.current[sectionId] || [];
try {
await fetch(`${wpAgenticWriter.apiUrl}/section-blocks`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-WP-Nonce": wpAgenticWriter.nonce,
},
body: JSON.stringify({
postId: postId,
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(/<code>([\s\S]*?)<\/code>/i);
if (match && match[1]) {
attrs.content = match[1]
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"');
}
}
// Handle table blocks - extract head and body from innerHTML
if (block.blockName === "core/table" && block.innerHTML) {
const headMatch = block.innerHTML.match(/<thead>([\s\S]*?)<\/thead>/i);
const bodyMatch = block.innerHTML.match(/<tbody>([\s\S]*?)<\/tbody>/i);
if (headMatch || bodyMatch) {
attrs.head = [];
attrs.body = [];
// Parse thead rows
if (headMatch) {
const headRows = headMatch[1].match(/<tr>([\s\S]*?)<\/tr>/gi) || [];
headRows.forEach((row) => {
const cells = [];
const cellMatches =
row.match(/<t[hd]>([\s\S]*?)<\/t[hd]>/gi) || [];
cellMatches.forEach((cell) => {
const content = cell.replace(/<\/?t[hd]>/gi, "");
cells.push({ content, tag: "th" });
});
if (cells.length > 0) attrs.head.push({ cells });
});
}
// Parse tbody rows
if (bodyMatch) {
const bodyRows = bodyMatch[1].match(/<tr>([\s\S]*?)<\/tr>/gi) || [];
bodyRows.forEach((row) => {
const cells = [];
const cellMatches = row.match(/<td>([\s\S]*?)<\/td>/gi) || [];
cellMatches.forEach((cell) => {
const content = cell.replace(/<\/?td>/gi, "");
cells.push({ content, tag: "td" });
});
if (cells.length > 0) attrs.body.push({ cells });
});
}
}
}
// Handle button blocks from [CTA:...] syntax
if (
block.blockName === "core/buttons" ||
block.blockName === "core/button"
) {
if (block.blockName === "core/button") {
return wp.blocks.createBlock("core/buttons", {}, [
wp.blocks.createBlock("core/button", attrs),
]);
}
}
if (block.innerBlocks && block.innerBlocks.length > 0) {
const innerBlocks = block.innerBlocks
.map((innerBlock) => createBlocksFromSerialized(innerBlock))
.filter(Boolean);
return wp.blocks.createBlock(block.blockName, attrs, innerBlocks);
}
return wp.blocks.createBlock(block.blockName, attrs);
};
const reformatBlocks = async (blocksToReformat, originalMessage) => {
if (isLoading) {
return;
}
if (!blocksToReformat || blocksToReformat.length === 0) {
setMessages((prev) => [
...prev,
{
role: "system",
type: "error",
content: "No blocks found to reformat.",
},
]);
return;
}
setIsLoading(true);
setMessages((prev) => [
...deactivateActiveTimelineEntries(prev),
{
role: "system",
type: "timeline",
status: "refining",
message: `Reformatting ${blocksToReformat.length} block(s)...`,
timestamp: new Date(),
},
]);
try {
const response = await fetch(
wpAgenticWriter.apiUrl + "/reformat-blocks",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-WP-Nonce": wpAgenticWriter.nonce,
},
body: JSON.stringify({
blocks: blocksToReformat,
postId: postId,
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;
}
// Capture snapshot before applying changes
pushUndoSnapshot("Apply Edit Plan");
const { replaceBlocks, insertBlocks, removeBlocks } =
dispatch("core/block-editor");
const allBlocks = select("core/block-editor").getBlocks();
const baseIndexById = new Map(
allBlocks.map((block, index) => [block.clientId, index]),
);
const insertOffsets = {};
const existingIds = new Set(allBlocks.map((block) => block.clientId));
actions.forEach((action) => {
if (action.action === "keep") {
return;
}
if (action.blockId && !existingIds.has(action.blockId)) {
return;
}
if (action.action === "delete" && action.blockId) {
removeBlocks(action.blockId);
return;
}
if (action.action === "change_type" && action.blockId) {
const newBlock = createBlockFromPlan(action);
replaceBlocks(action.blockId, newBlock);
return;
}
if (action.action === "replace" && action.blockId) {
const newBlock = createBlockFromPlan(action);
replaceBlocks(action.blockId, newBlock);
return;
}
if (
(action.action === "insert_after" ||
action.action === "insert_before") &&
action.blockId
) {
const baseIndex = baseIndexById.get(action.blockId);
const offsets = insertOffsets[action.blockId] || {
before: 0,
after: 0,
};
let insertIndex;
if (typeof baseIndex === "number") {
if (action.action === "insert_before") {
insertIndex = baseIndex + offsets.before;
offsets.before += 1;
} else {
insertIndex = baseIndex + offsets.before + 1 + offsets.after;
offsets.after += 1;
}
}
insertOffsets[action.blockId] = offsets;
const newBlock = createBlockFromPlan(action);
insertBlocks(newBlock, insertIndex);
}
});
setPendingEditPlan(null);
setMessages((prev) => [
...prev,
{
role: "system",
type: "timeline",
status: "complete",
message: "Changes applied.",
},
]);
};
const cancelEditPlan = () => {
setPendingEditPlan(null);
setMessages((prev) => [
...prev,
{
role: "system",
type: "timeline",
status: "inactive",
message: "Changes cancelled.",
},
]);
};
const formatClarificationContext = (questionsList, answersMap) => {
if (!questionsList || questionsList.length === 0) {
return "";
}
const lines = [];
questionsList.forEach((question) => {
const answer = answersMap[question.id];
if (!answer) {
return;
}
lines.push(
`- ${question.question || question.prompt || "Question"}: ${answer}`,
);
});
if (lines.length === 0) {
return "";
}
return `\n\nClarification Answers:\n${lines.join("\n")}`;
};
// Auto-select first option when question changes
React.useEffect(() => {
if (
inClarification &&
questions.length > 0 &&
questions[currentQuestionIndex]
) {
const currentQuestion = questions[currentQuestionIndex];
if (
currentQuestion.type === "single_choice" &&
currentQuestion.options &&
currentQuestion.options.length > 0 &&
!answers[currentQuestion.id]
) {
const newAnswers = { ...answers };
newAnswers[currentQuestion.id] = currentQuestion.options[0].value;
setAnswers(newAnswers);
}
}
}, [currentQuestionIndex, questions, inClarification]);
/**
* Remove duplicate adjacent heading blocks
*/
const removeDuplicateHeadings = (blocks) => {
if (!blocks || blocks.length === 0) {
return blocks;
}
const cleanedBlocks = [];
let lastHeadingContent = null;
for (const block of blocks) {
if (block.name === "core/heading") {
const currentHeading = (block.attributes?.content || "")
.trim()
.toLowerCase();
if (currentHeading === lastHeadingContent) {
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);
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: "Dont 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>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/paragraph", {
content: content,
});
} else if (data.block.blockName === "core/heading") {
const level = data.block.attrs?.level || 2;
const content =
data.block.innerHTML?.match(
/<h[1-6]>(.*?)<\/h[1-6]>/,
)?.[1] || "";
newBlock = wp.blocks.createBlock("core/heading", {
level: level,
content: content,
});
} else if (data.block.blockName === "core/list") {
const listItems = (data.block.innerBlocks || []).map(
(item) => {
const content =
item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || "";
return wp.blocks.createBlock("core/list-item", {
content: content,
});
},
);
newBlock = wp.blocks.createBlock(
"core/list",
{
...(data.block.attrs || {}),
ordered: data.block.attrs?.ordered || false,
},
listItems,
);
} else if (data.block.blockName === "core/quote") {
const content =
data.block.innerHTML?.match(/<p>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/quote", {
value: content,
});
} else if (data.block.blockName === "core/image") {
newBlock = wp.blocks.createBlock(
"core/image",
data.block.attrs || {},
);
}
if (newBlock) {
insertBlocks(newBlock);
}
} else if (data.type === "complete") {
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>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/paragraph", {
content: content,
});
} else if (data.block.blockName === "core/heading") {
const level = data.block.attrs?.level || 2;
const content =
data.block.innerHTML?.match(
/<h[1-6]>(.*?)<\/h[1-6]>/,
)?.[1] || "";
newBlock = wp.blocks.createBlock("core/heading", {
level: level,
content: content,
});
} else if (data.block.blockName === "core/list") {
const listItems = (data.block.innerBlocks || []).map(
(item) => {
const content =
item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || "";
return wp.blocks.createBlock("core/list-item", {
content: content,
});
},
);
newBlock = wp.blocks.createBlock(
"core/list",
{
...(data.block.attrs || {}),
ordered: data.block.attrs?.ordered || false,
},
listItems,
);
} else if (data.block.blockName === "core/quote") {
const content =
data.block.innerHTML?.match(/<p>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/quote", {
value: content,
});
} else if (data.block.blockName === "core/image") {
newBlock = wp.blocks.createBlock(
"core/image",
data.block.attrs || {},
);
} else {
const parsed = wp.blocks.parse(data.block.innerHTML);
newBlock = parsed && parsed.length > 0 ? parsed[0] : null;
}
if (newBlock) {
insertBlocks(newBlock);
}
} else if (data.type === "complete") {
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>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/paragraph", {
content: content,
});
} else if (data.block.blockName === "core/heading") {
const level = data.block.attrs?.level || 2;
const content =
data.block.innerHTML?.match(
/<h[1-6]>(.*?)<\/h[1-6]>/,
)?.[1] || "";
newBlock = wp.blocks.createBlock("core/heading", {
level: level,
content: content,
});
} else if (data.block.blockName === "core/list") {
const listItems = (data.block.innerBlocks || []).map(
(item) => {
const content =
item.innerHTML?.match(/<li>(.*?)<\/li>/)?.[1] || "";
return wp.blocks.createBlock("core/list-item", {
content: content,
});
},
);
newBlock = wp.blocks.createBlock(
"core/list",
{
...(data.block.attrs || {}),
ordered: data.block.attrs?.ordered || false,
},
listItems,
);
} else if (data.block.blockName === "core/quote") {
const content =
data.block.innerHTML?.match(/<p>(.*?)<\/p>/)?.[1] || "";
newBlock = wp.blocks.createBlock("core/quote", {
value: content,
});
} else if (data.block.blockName === "core/image") {
newBlock = wp.blocks.createBlock(
"core/image",
data.block.attrs || {},
);
}
if (newBlock) {
insertBlocks(newBlock);
}
} else if (data.type === "complete") {
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("input", {
type: "text",
className: "wpaw-custom-text-input",
placeholder: "Type your answer here...",
value: customValue,
onChange: (e) => {
const newAnswers = { ...answers };
newAnswers[customInputKey] = e.target.value;
setAnswers(newAnswers);
},
autoFocus: true,
}),
),
);
};
// Helper to render multiple choice options
const renderMultipleChoice = () => {
const selectedValues = currentAnswer ? currentAnswer.split(", ") : [];
return wp.element.createElement(
"div",
{ className: "wpaw-answer-options" },
currentQuestion.options.map((option, idx) => {
const isSelected = selectedValues.includes(option.value);
return wp.element.createElement(
"label",
{ key: idx },
wp.element.createElement("input", {
type: "checkbox",
checked: isSelected,
onChange: () => {
const newAnswers = { ...answers };
let newSelected = isSelected
? selectedValues.filter((v) => v !== option.value)
: [...selectedValues, option.value];
newAnswers[currentQuestion.id] = newSelected.join(", ");
setAnswers(newAnswers);
},
}),
wp.element.createElement("span", null, option.value),
);
}),
);
};
// Helper to render open text textarea
const renderOpenText = () => {
return wp.element.createElement(
"div",
{ className: "wpaw-answer-options" },
wp.element.createElement(TextareaControl, {
placeholder:
currentQuestion.placeholder || "Type your answer here...",
value: currentAnswer,
onChange: (value) => {
const newAnswers = { ...answers };
newAnswers[currentQuestion.id] = value;
setAnswers(newAnswers);
},
rows: 4,
maxLength: currentQuestion.max_length || 500,
}),
);
};
// Helper to render config form (consolidated config page)
const renderConfigForm = () => {
// Initialize with defaults if no answer exists
let configData = {};
if (currentAnswer) {
try {
configData = JSON.parse(currentAnswer);
} catch (e) {
configData = {};
}
}
// Set defaults from field definitions if not already set
const fields = currentQuestion.fields || [];
fields.forEach((field) => {
if (
configData[field.id] === undefined &&
field.default !== undefined
) {
configData[field.id] = field.default;
}
});
// Initialize answer with defaults on first render
if (!currentAnswer && Object.keys(configData).length > 0) {
const newAnswers = { ...answers };
newAnswers[currentQuestion.id] = JSON.stringify(configData);
setAnswers(newAnswers);
}
return wp.element.createElement(
"div",
{ className: "wpaw-config-form" },
fields.map((field, idx) => {
const fieldValue =
configData[field.id] !== undefined
? configData[field.id]
: field.default;
const isConditional =
field.conditional && !configData[field.conditional];
if (isConditional) {
return null;
}
return wp.element.createElement(
"div",
{ key: idx, className: "wpaw-config-field" },
field.type === "toggle"
? wp.element.createElement(
React.Fragment,
null,
wp.element.createElement(
"label",
{ className: "wpaw-config-label" },
wp.element.createElement(
"span",
{ className: "wpaw-config-label-text" },
field.label,
),
field.description &&
wp.element.createElement(
"span",
{ className: "wpaw-config-description" },
field.description,
),
),
wp.element.createElement(
"label",
{ className: "wpaw-config-toggle" },
wp.element.createElement("input", {
type: "checkbox",
checked: fieldValue || false,
onChange: (e) => {
const newConfig = { ...configData };
newConfig[field.id] = e.target.checked;
const newAnswers = { ...answers };
newAnswers[currentQuestion.id] =
JSON.stringify(newConfig);
setAnswers(newAnswers);
},
}),
wp.element.createElement("span", {
className: "wpaw-toggle-slider",
}),
),
)
: wp.element.createElement(
React.Fragment,
null,
wp.element.createElement(
"label",
{ className: "wpaw-config-label" },
wp.element.createElement(
"span",
{ className: "wpaw-config-label-text" },
field.label,
),
field.description &&
wp.element.createElement(
"span",
{ className: "wpaw-config-description" },
field.description,
),
),
wp.element.createElement("input", {
type: "text",
className: "wpaw-config-text-input",
placeholder: field.placeholder || "",
value: fieldValue || "",
maxLength: field.max_length || 200,
onChange: (e) => {
const newConfig = { ...configData };
newConfig[field.id] = e.target.value;
const newAnswers = { ...answers };
newAnswers[currentQuestion.id] =
JSON.stringify(newConfig);
setAnswers(newAnswers);
},
}),
),
);
}),
);
};
// Render appropriate input type based on question type
let answerInput;
switch (currentQuestion.type) {
case "single_choice":
answerInput = renderSingleChoice();
break;
case "multiple_choice":
answerInput = renderMultipleChoice();
break;
case "open_text":
answerInput = renderOpenText();
break;
case "config_form":
answerInput = renderConfigForm();
break;
default:
answerInput = renderSingleChoice();
}
return wp.element.createElement(
"div",
{ className: "wpaw-clarification-quiz dark-theme" },
wp.element.createElement(
"div",
{ className: "wpaw-quiz-header" },
wp.element.createElement("h3", null, " Clarification Questions"),
wp.element.createElement(
"div",
{ className: "wpaw-progress-bar" },
wp.element.createElement("div", {
className: "wpaw-progress-fill",
style: {
width:
((currentQuestionIndex + 1) / questions.length) * 100 + "%",
},
}),
),
wp.element.createElement(
"span",
null,
`${currentQuestionIndex + 1} of ${questions.length}`,
),
),
wp.element.createElement(
"div",
{ className: "wpaw-question-card" },
wp.element.createElement("h4", null, currentQuestion.question),
answerInput,
wp.element.createElement(
"div",
{ className: "wpaw-quiz-actions" },
// Previous button
currentQuestionIndex > 0 &&
wp.element.createElement(
Button,
{
isSecondary: true,
onClick: () =>
setCurrentQuestionIndex(currentQuestionIndex - 1),
disabled: isLoading,
},
"Previous",
),
// Skip button for optional questions
wp.element.createElement(
Button,
{
isSecondary: true,
onClick: () => {
const newAnswers = { ...answers };
newAnswers[currentQuestion.id] = "__skipped__";
setAnswers(newAnswers);
if (currentQuestionIndex === questions.length - 1) {
submitAnswers();
} else {
setCurrentQuestionIndex(currentQuestionIndex + 1);
}
},
disabled: isLoading,
},
"Skip",
),
// Continue/Finish button
wp.element.createElement(
Button,
{
isPrimary: true,
onClick: () => {
if (currentQuestionIndex === questions.length - 1) {
submitAnswers();
} else {
setCurrentQuestionIndex(currentQuestionIndex + 1);
}
},
disabled:
isLoading ||
(!currentAnswer.trim() && currentAnswer !== "__custom__"),
},
currentQuestionIndex === questions.length - 1 ? "Finish" : "Next",
),
),
),
);
};
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
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: "",
});
await loadPostSessions();
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:
'<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12 5a3 3 0 1 0-5.997.125a4 4 0 0 0-2.526 5.77a4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M9 13a4.5 4.5 0 0 0 3-4M6.003 5.125A3 3 0 0 0 6.401 6.5m-2.924 4.396a4 4 0 0 1 .585-.396M6 18a4 4 0 0 1-1.967-.516M12 13h4m-4 5h6a2 2 0 0 1 2 2v1M12 8h8m-4 0V5a2 2 0 0 1 2-2"/><circle cx="16" cy="13" r=".5"/><circle cx="18" cy="3" r=".5"/><circle cx="20" cy="21" r=".5"/><circle cx="20" cy="8" r=".5"/></g></svg>',
},
}),
wp.element.createElement(
"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:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="width: 60px; height: 60px;"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M12 5a3 3 0 1 0-5.997.125a4 4 0 0 0-2.526 5.77a4 4 0 0 0 .556 6.588A4 4 0 1 0 12 18Z"/><path d="M9 13a4.5 4.5 0 0 0 3-4M6.003 5.125A3 3 0 0 0 6.401 6.5m-2.924 4.396a4 4 0 0 1 .585-.396M6 18a4 4 0 0 1-1.967-.516M12 13h4m-4 5h6a2 2 0 0 1 2 2v1M12 8h8m-4 0V5a2 2 0 0 1 2-2"/><circle cx="16" cy="13" r=".5"/><circle cx="18" cy="3" r=".5"/><circle cx="20" cy="21" r=".5"/><circle cx="20" cy="8" r=".5"/></g></svg>',
},
}),
wp.element.createElement("h3", null, "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,
),
wp.element.createElement(
"button",
{
type: "button",
className: "wpaw-agent-workspace-toggle",
onClick: toggleAgentWorkspace,
"aria-expanded": !isWorkspaceCollapsed,
title: isWorkspaceCollapsed
? "Show Agent Workspace"
: "Hide Agent Workspace",
},
isWorkspaceCollapsed ? "Show" : "Hide",
),
),
),
!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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
};
const inlineMarkdownToHtml = (text) => {
let html = escapeHtml(text);
html = html.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
(match, label, url) =>
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`,
);
html = html.replace(
/`([^`]+)`/g,
(match, code) => `<code>${escapeHtml(code)}</code>`,
);
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
html = html.replace(/__([^_]+)__/g, "<strong>$1</strong>");
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
html = html.replace(/_([^_]+)_/g, "<em>$1</em>");
return html;
};
const markdownToHtml = (markdown) => {
const raw = normalizeMessageContent(markdown);
if (!raw) {
return "";
}
if (window.markdownit && window.DOMPurify) {
if (!markdownRendererRef.current) {
const renderer = window.markdownit({
html: false,
linkify: true,
breaks: false,
});
if (window.markdownitTaskLists) {
renderer.use(window.markdownitTaskLists, {
enabled: true,
label: true,
labelAfter: true,
});
}
const defaultLinkOpen =
renderer.renderer.rules.link_open ||
function (tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
renderer.renderer.rules.link_open = function (
tokens,
idx,
options,
env,
self,
) {
const token = tokens[idx];
const targetIndex = token.attrIndex("target");
if (targetIndex < 0) {
token.attrPush(["target", "_blank"]);
} else {
token.attrs[targetIndex][1] = "_blank";
}
const relIndex = token.attrIndex("rel");
if (relIndex < 0) {
token.attrPush(["rel", "noopener noreferrer"]);
} else {
token.attrs[relIndex][1] = "noopener noreferrer";
}
return defaultLinkOpen(tokens, idx, options, env, self);
};
markdownRendererRef.current = renderer;
}
const rendered = markdownRendererRef.current.render(raw);
return window.DOMPurify.sanitize(rendered, {
USE_PROFILES: { html: true },
ADD_TAGS: ["input", "label"],
ADD_ATTR: ["type", "checked", "disabled", "class"],
});
}
const codeBlocks = [];
let text = raw.replace(
/```(\w+)?\n([\s\S]*?)```/g,
(match, lang, code) => {
const safeLang = lang
? ` class="language-${escapeHtml(lang)}"`
: "";
const index = codeBlocks.length;
codeBlocks.push(
`<pre><code${safeLang}>${escapeHtml(code)}</code></pre>`,
);
return `@@CODEBLOCK${index}@@`;
},
);
const lines = text.split(/\r?\n/);
let html = "";
let paragraph = [];
let list = null;
let detailBreak = false;
let lastLineWasListItem = false;
const flushParagraph = () => {
if (paragraph.length) {
html += `<p>${inlineMarkdownToHtml(paragraph.join(" "))}</p>`;
paragraph = [];
}
};
const flushList = () => {
if (list) {
const items = list.items
.map((item) => {
const details =
item.details && item.details.length > 0
? item.details
.map(
(detail) => `<p>${inlineMarkdownToHtml(detail)}</p>`,
)
.join("")
: "";
const children =
item.children && item.children.length > 0
? `<ul>${item.children.map((child) => `<li>${inlineMarkdownToHtml(child)}</li>`).join("")}</ul>`
: "";
return `<li>${inlineMarkdownToHtml(item.content)}${details}${children}</li>`;
})
.join("");
html += `<${list.type}>${items}</${list.type}>`;
list = null;
}
};
const addListItem = (targetList, value) => {
targetList.items.push({ content: value, children: [], details: [] });
lastLineWasListItem = true;
};
const addDetailToLastItem = (targetList, value, newParagraph) => {
const lastItem = targetList.items[targetList.items.length - 1];
if (!lastItem) {
return;
}
if (newParagraph || lastItem.details.length === 0) {
lastItem.details.push(value);
} else {
lastItem.details[lastItem.details.length - 1] += ` ${value}`;
}
lastLineWasListItem = false;
};
const getListType = (value) => {
if (/^\d+\.\s+/.test(value)) {
return "ol";
}
if (/^[-*+]\s+/.test(value)) {
return "ul";
}
return null;
};
for (let i = 0; i < lines.length; i++) {
const trimmed = lines[i].trim();
if (trimmed === "") {
let nextIndex = i + 1;
while (nextIndex < lines.length && lines[nextIndex].trim() === "") {
nextIndex += 1;
}
const nextLine =
nextIndex < lines.length ? lines[nextIndex].trim() : "";
const nextType = getListType(nextLine);
if (list && nextType && nextType === list.type) {
continue;
}
if (
list &&
list.type === "ol" &&
nextLine &&
!nextType &&
!nextLine.startsWith("@@CODEBLOCK") &&
!/^(#{1,6})\s+/.test(nextLine)
) {
detailBreak = true;
lastLineWasListItem = false;
continue;
}
flushList();
flushParagraph();
lastLineWasListItem = false;
continue;
}
if (trimmed.startsWith("@@CODEBLOCK")) {
flushList();
flushParagraph();
html += trimmed;
lastLineWasListItem = false;
continue;
}
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
if (headingMatch) {
flushList();
flushParagraph();
const level = headingMatch[1].length;
html += `<h${level}>${inlineMarkdownToHtml(headingMatch[2])}</h${level}>`;
lastLineWasListItem = false;
continue;
}
const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/);
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
if (unorderedMatch || orderedMatch) {
flushParagraph();
detailBreak = false;
const type = orderedMatch ? "ol" : "ul";
let value =
(orderedMatch ? orderedMatch[1] : unorderedMatch[1]) || "";
if (orderedMatch) {
value = value.replace(/^\d+\.\s+/, "");
}
if (
!orderedMatch &&
list &&
list.type === "ol" &&
list.items.length > 0
) {
list.items[list.items.length - 1].children.push(value);
continue;
}
if (!list || list.type !== type) {
flushList();
list = { type, items: [] };
}
addListItem(list, value);
continue;
}
if (
list &&
list.type === "ol" &&
(lastLineWasListItem || detailBreak)
) {
addDetailToLastItem(list, trimmed, detailBreak);
detailBreak = false;
continue;
}
if (list) {
flushList();
}
paragraph.push(trimmed);
lastLineWasListItem = false;
}
flushList();
flushParagraph();
codeBlocks.forEach((block, index) => {
html = html.replace(`@@CODEBLOCK${index}@@`, block);
});
return html;
};
const renderMessageContent = (content, allowMarkdown) => {
if (!allowMarkdown) {
return normalizeMessageContent(content);
}
return wp.element.createElement(RawHTML, null, markdownToHtml(content));
};
const lastActiveTimelineIndex = findLastActiveTimelineIndex(messages);
const groups = [];
let currentAiGroup = null;
messages.forEach((message, index) => {
if (message.role === "user") {
groups.push({ type: "user", message, key: `user-${index}` });
currentAiGroup = null;
return;
}
if (!currentAiGroup) {
currentAiGroup = { type: "ai", items: [], key: `ai-${index}` };
groups.push(currentAiGroup);
}
currentAiGroup.items.push({ message, index });
});
return groups.map((group, groupIndex) => {
if (group.type === "user") {
return wp.element.createElement(
"div",
{
key: group.key,
className: "wpaw-message wpaw-message-user",
},
wp.element.createElement(
"div",
{ className: "wpaw-message-content" },
renderMessageContent(group.message.content, false),
),
);
}
const isLastGroup = groupIndex === groups.length - 1;
let streamingLabel = "Streaming...";
for (let i = group.items.length - 1; i >= 0; i--) {
const item = group.items[i].message;
if (item.type === "timeline" && item.status) {
if (item.status === "checking") {
streamingLabel = "Analyzing...";
} else if (
item.status === "planning" ||
item.status === "plan_complete"
) {
streamingLabel = "Planning...";
} else if (
item.status === "writing" ||
item.status === "writing_section"
) {
streamingLabel = "Writing...";
} else if (item.status === "refining") {
streamingLabel = "Refining...";
} else {
streamingLabel = "Streaming...";
}
break;
}
}
return wp.element.createElement(
"div",
{
key: group.key,
className: "wpaw-ai-response",
},
group.items.map((item, itemIndex) => {
const message = item.message;
const index = item.index;
const isLastItem = itemIndex === group.items.length - 1;
if (message.type === "timeline") {
const statusClass =
message.status === "complete"
? "complete"
: message.status === "inactive"
? "inactive"
: "active";
const showProcessing = isLoading && message.status === "refining";
const elapsedTime =
message.status === "complete" &&
message.timestamp &&
message.completedAt
? (
(new Date(message.completedAt) -
new Date(message.timestamp)) /
1000
).toFixed(1) + "s"
: null;
return wp.element.createElement(
"div",
{
key: `timeline-${index}`,
className:
"wpaw-ai-item wpaw-timeline-entry " +
statusClass +
(index === lastActiveTimelineIndex ? " is-current" : ""),
},
wp.element.createElement("div", {
className: "wpaw-timeline-dot",
"aria-hidden": "true",
}),
wp.element.createElement(
"div",
{ className: "wpaw-timeline-content" },
wp.element.createElement(
"div",
{ className: "wpaw-timeline-message" },
normalizeMessageContent(message.message),
),
message.status === "complete" &&
wp.element.createElement(
"div",
{ className: "wpaw-timeline-complete" },
"✓ Complete",
elapsedTime &&
wp.element.createElement(
"span",
{ className: "wpaw-timeline-elapsed" },
` (${elapsedTime})`,
),
),
showProcessing &&
wp.element.createElement(
"div",
{ className: "wpaw-processing-indicator" },
wp.element.createElement("span", {
className: "wpaw-dots-loader",
}),
wp.element.createElement(
"span",
null,
"Processing updates…",
),
),
!showProcessing &&
isLoading &&
isLastGroup &&
isLastItem &&
wp.element.createElement(
"div",
{
className: "wpaw-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" },
item.title,
),
item.target &&
wp.element.createElement(
"button",
{
type: "button",
className: "wpaw-edit-plan-item-target",
disabled: !isPlanActive,
onClick: () => {
if (!isPlanActive || !item.blockId) {
return;
}
dispatch("core/block-editor").selectBlock(
item.blockId,
);
const targetNode = document.querySelector(
`[data-block="${item.blockId}"]`,
);
if (targetNode) {
targetNode.scrollIntoView({
behavior: "smooth",
block: "center",
});
}
},
},
`${item.targetLabel} ${item.target}`,
),
item.before &&
wp.element.createElement(
"div",
{ className: "wpaw-edit-plan-item-before" },
`Before ${item.before}`,
),
item.after &&
wp.element.createElement(
"div",
{ className: "wpaw-edit-plan-item-after" },
`Add ${item.after}`,
),
),
),
),
wp.element.createElement(
"div",
{ className: "wpaw-edit-plan-actions" },
wp.element.createElement(
Button,
{
isPrimary: true,
onClick: () => applyEditPlan(plan),
disabled: !plan || !isPlanActive,
},
`Apply (${actionCount})`,
),
wp.element.createElement(
Button,
{
isSecondary: true,
onClick: cancelEditPlan,
disabled: !isPlanActive,
},
"Cancel",
),
),
);
}
if (message.type === "error") {
const handleRetry = () => {
if (message.retryType === "execute") {
retryLastExecute();
return;
}
if (message.retryType === "refine") {
retryLastRefinement();
return;
}
if (message.retryType === "chat") {
retryLastChat();
return;
}
retryLastGeneration();
};
// 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(
"button",
{
className: "wpaw-back-btn",
onClick: () => setActiveTab("chat"),
},
"← Back",
),
wp.element.createElement("h3", null, "CONFIGURATION"),
),
wp.element.createElement(
"div",
{ className: "wpaw-config-section" },
wp.element.createElement("label", null, "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:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fbbf24" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>',
},
}),
" Generating...",
)
: wp.element.createElement(
"span",
{
style: {
display: "flex",
alignItems: "center",
gap: "5px",
},
},
wp.element.createElement("span", {
className: "wpaw-svg-wrapper",
dangerouslySetInnerHTML: {
__html:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><g fill="none" stroke="#fbbf24" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"><path d="M11.017 2.814a1 1 0 0 1 1.966 0l1.051 5.558a2 2 0 0 0 1.594 1.594l5.558 1.051a1 1 0 0 1 0 1.966l-5.558 1.051a2 2 0 0 0-1.594 1.594l-1.051 5.558a1 1 0 0 1-1.966 0l-1.051-5.558a2 2 0 0 0-1.594-1.594l-5.558-1.051a1 1 0 0 1 0-1.966l5.558-1.051a2 2 0 0 0 1.594-1.594zM20 2v4m2-2h-4"/><circle cx="4" cy="20" r="2"/></g></svg>',
},
}),
" Generate",
),
),
),
),
// SEO Audit Section
postConfig.seo_enabled &&
wp.element.createElement(
"div",
{ className: "wpaw-config-section wpaw-seo-audit" },
wp.element.createElement(
"div",
{ className: "wpaw-seo-audit-header" },
wp.element.createElement("label", null, "SEO Audit"),
wp.element.createElement(
Button,
{
isSecondary: true,
isSmall: true,
onClick: () => runSeoAudit(),
disabled: isConfigDisabled || isSeoAuditing,
},
isSeoAuditing
? wp.element.createElement(
"span",
{
style: {
display: "flex",
alignItems: "center",
gap: "5px",
},
},
wp.element.createElement("span", {
className: "wpaw-spinning-icon",
style: { display: "inline-flex", lineHeight: "0" },
dangerouslySetInnerHTML: {
// Icon Loader/Circle-slashed untuk kesan analyzing
__html:
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#4ade80" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="m16.2 7.8 2.9-2.9"/><path d="M18 12h4"/><path d="m16.2 16.2 2.9 2.9"/><path d="M12 18v4"/><path d="m4.9 19.1 2.9-2.9"/><path d="M2 12h4"/><path d="m4.9 4.9 2.9 2.9"/></svg>',
},
}),
" Analyzing...",
)
: wp.element.createElement(
"span",
{
style: {
display: "flex",
alignItems: "center",
gap: "5px",
},
},
wp.element.createElement("span", {
className: "wpaw-svg-wrapper",
style: { display: "inline-flex", lineHeight: "0" },
dangerouslySetInnerHTML: {
// Icon Bar-Chart untuk "Run Audit"
__html:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="none" stroke="#4ade80" stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M13 5h8m-8 7h8m-8 7h8M3 17l2 2l4-4M3 7l2 2l4-4"/></svg>',
},
}),
" Run Audit",
),
),
),
seoAudit &&
wp.element.createElement(
"div",
{ className: "wpaw-seo-audit-results" },
wp.element.createElement(
"div",
{
className:
"wpaw-seo-score " +
(seoAudit.score >= 70
? "good"
: seoAudit.score >= 40
? "warning"
: "poor"),
},
wp.element.createElement(
"span",
{ className: "score-value" },
seoAudit.score,
),
wp.element.createElement(
"span",
{ className: "score-label" },
"/100",
),
),
wp.element.createElement(
"div",
{ className: "wpaw-seo-stats" },
wp.element.createElement(
"div",
{ className: "wpaw-seo-stat" },
wp.element.createElement(
"span",
{ className: "stat-label" },
"Words",
),
wp.element.createElement(
"span",
{ className: "stat-value" },
seoAudit.word_count || 0,
),
),
wp.element.createElement(
"div",
{ className: "wpaw-seo-stat" },
wp.element.createElement(
"span",
{ className: "stat-label" },
"Keyword Density",
),
wp.element.createElement(
"span",
{ className: "stat-value" },
`${(seoAudit.keyword_density || 0).toFixed(1)}%`,
),
),
),
seoAudit.checks &&
wp.element.createElement(
"div",
{ className: "wpaw-seo-checks" },
seoAudit.checks.map((check, idx) => {
const isPassed =
check.status === "good" || check.status === "ok";
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",
),
),
),
);
};
// Render Chat Tab
const renderChatTab = () => {
const agentBusy = isLoading || isSeoAuditing || isGeneratingMeta;
const isStoppingOperation = activeOperation.status === "stopping";
// Determine agent status
const getAgentStatus = () => {
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 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-tab-content wpaw-chat-tab dark-theme" },
renderClarification(),
!inClarification &&
wp.element.createElement(
"div",
{ className: "wpaw-chat-container" },
// Status Bar
wp.element.createElement(
"div",
{
className: "wpaw-status-bar",
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" },
!showWelcome &&
wp.element.createElement(
"button",
{
className: "wpaw-status-icon-btn",
title: "Back to Sessions",
onClick: () => setShowWelcome(true),
disabled: isLoading,
},
"Sessions",
),
// Undo Button
aiUndoStack.length > 0 &&
wp.element.createElement(
"button",
{
className: "wpaw-status-icon-btn wpaw-undo-btn",
title: `Undo: ${aiUndoStack[aiUndoStack.length - 1]?.label || "Last AI operation"}`,
onClick: undoLastAiOperation,
disabled: isLoading,
},
"↩️",
),
// Cost Label
// wp.element.createElement('span', { className: 'wpaw-status-cost' },
// 'Session: $' + cost.session.toFixed(4)
// ),
// Config Icon Button
wp.element.createElement("button", {
className: "wpaw-status-icon-btn",
dangerouslySetInnerHTML: {
__html:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M14 17H5M19 7h-9"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/></g></svg>',
},
title: "Configuration",
onClick: () => setActiveTab("config"),
disabled: isLoading,
}),
// Cost Icon Button
wp.element.createElement("button", {
className: "wpaw-status-icon-btn",
dangerouslySetInnerHTML: {
__html:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/></g></svg>',
},
title: "Cost Tracking",
onClick: () => setActiveTab("cost"),
disabled: isLoading,
}),
),
),
// 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)).`,
),
// 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" },
},
// Slash command hint when input is empty
!input &&
!isLoading &&
wp.element.createElement(
"div",
{ className: "wpaw-input-hint" },
"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(TextareaControl, {
ref: inputRef,
value: input,
onChange: handleInputChange,
onKeyDown: handleKeyDown,
rows: isTextareaExpanded ? 20 : 3,
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:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20a14.5 14.5 0 0 0 0-20M2 12h20"/></g></svg>',
},
}),
wp.element.createElement(
"span",
{ className: "wpaw-web-search-label" },
searchBlocked ? "Search ✕" : "Search",
),
);
})(),
),
wp.element.createElement(
"div",
{ className: "wpaw-command-actions-group" },
!showWelcome &&
wp.element.createElement(
"button",
{
className: "wpaw-command-text-btn",
type: "button",
onClick: () => setShowWelcome(true),
disabled: isLoading,
},
"Sessions",
),
// New session keeps previous agent sessions continuable.
wp.element.createElement(
"button",
{
className: "wpaw-command-text-btn",
type: "button",
onClick: clearChatContext,
disabled: isLoading,
},
"New Session",
),
// 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
? '<svg class="wpaw-stop-spinner" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M12 3a9 9 0 1 1-8.5 6"/><path d="M12 7v5l3 2"/></g></svg>'
: '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M11.945 5.75c-1.367 0-2.47 0-3.337.117c-.9.12-1.658.38-2.26.981c-.602.602-.86 1.36-.981 2.26c-.117.867-.117 1.97-.117 3.337v.11c0 1.367 0 2.47.117 3.337c.12.9.38 1.658.981 2.26c.602.602 1.36.86 2.26.982c.867.116 1.97.116 3.337.116h.11c1.367 0 2.47 0 3.337-.116c.9-.122 1.658-.38 2.26-.982s.86-1.36.982-2.26c.116-.867.116-1.97.116-3.337v-.11c0-1.367 0-2.47-.116-3.337c-.122-.9-.38-1.658-.982-2.26s-1.36-.86-2.26-.981c-.867-.117-1.97-.117-3.337-.117z"/></svg>',
},
}),
// Send Button (Bottom Right) - Circle with send icon
!agentBusy &&
wp.element.createElement("button", {
className:
"wpaw-command-circle-btn wpaw-send-circle-btn",
type: "button",
onClick: sendMessage,
disabled: !input.trim(),
title: "Send message",
dangerouslySetInnerHTML: {
__html:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19.5 2.001a3.5 3.5 0 0 1 3.03 5.249l-7.5 12.99a3.5 3.5 0 0 1-6.411-.842l-1.5-5.595l8.77-5.064a1 1 0 0 0-1-1.732L6.12 12.07L2.026 7.975A3.5 3.5 0 0 1 4.5 2z"/></svg>',
},
}),
),
),
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);
}
};
// Render Cost Tab
const renderCostTab = () => {
const budgetPercent =
monthlyBudget > 0 ? (cost.monthlyUsed / monthlyBudget) * 100 : 0;
const budgetStatus =
budgetPercent > 90 ? "danger" : budgetPercent > 70 ? "warning" : "ok";
const remaining = Math.max(0, monthlyBudget - cost.monthlyUsed);
return wp.element.createElement(
"div",
{ className: "wpaw-tab-content wpaw-cost-tab dark-theme" },
wp.element.createElement(
"div",
{ className: "wpaw-tab-header" },
wp.element.createElement(
"button",
{
className: "wpaw-back-btn",
onClick: () => setActiveTab("chat"),
},
"← Back",
),
wp.element.createElement("h3", null, "OPENROUTER COST"),
wp.element.createElement("button", {
className: "wpaw-refresh-btn",
dangerouslySetInnerHTML: {
__html:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><g fill="none" stroke="#ffffff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M21 12a9 9 0 0 0-9-9a9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5m-5 4a9 9 0 0 0 9 9a9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></g></svg>',
},
onClick: refreshCostData,
title: "Refresh cost data",
}),
),
wp.element.createElement(
"div",
{ className: "wpaw-cost-card" },
wp.element.createElement(
"div",
{ className: "wpaw-cost-stat" },
wp.element.createElement("label", null, "This Post"),
wp.element.createElement(
"div",
{ className: "wpaw-cost-value" },
"$",
cost.session.toFixed(4),
),
),
wp.element.createElement(
"div",
{ className: "wpaw-cost-stat" },
wp.element.createElement("label", null, "Month Used"),
wp.element.createElement(
"div",
{ className: "wpaw-cost-value" },
"$",
cost.monthlyUsed.toFixed(4),
),
),
wp.element.createElement(
"div",
{ className: "wpaw-cost-stat wpaw-cost-remaining" },
wp.element.createElement("label", null, "Remaining"),
wp.element.createElement(
"div",
{ className: "wpaw-cost-value " + budgetStatus },
"$",
remaining.toFixed(2),
),
),
),
wp.element.createElement(
"div",
{ className: "wpaw-budget-section" },
wp.element.createElement(
"div",
{ className: "wpaw-budget-label" },
wp.element.createElement(
"span",
null,
"Budget: $",
monthlyBudget.toFixed(2),
),
wp.element.createElement(
"span",
null,
budgetPercent.toFixed(1),
"%",
),
),
wp.element.createElement(
"div",
{ className: "wpaw-budget-bar" },
wp.element.createElement("div", {
className: "wpaw-budget-fill " + budgetStatus,
style: { width: Math.min(budgetPercent, 100) + "%" },
}),
),
),
budgetPercent > 80 &&
wp.element.createElement(
"div",
{
className: "wpaw-budget-warning " + budgetStatus,
},
budgetPercent >= 100
? "⚠️ Budget exceeded!"
: "⚠️ Approaching budget limit",
),
costHistory.length > 0 &&
wp.element.createElement(
"div",
{ className: "wpaw-cost-history" },
wp.element.createElement("h4", null, "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:
'<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" style="margin-bottom: -7px;"><g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0a2.34 2.34 0 0 0 3.319 1.915a2.34 2.34 0 0 1 2.33 4.033a2.34 2.34 0 0 0 0 3.831a2.34 2.34 0 0 1-2.33 4.033a2.34 2.34 0 0 0-3.319 1.915a2.34 2.34 0 0 1-4.659 0a2.34 2.34 0 0 0-3.32-1.915a2.34 2.34 0 0 1-2.33-4.033a2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></g></svg> Manage Budget Settings',
},
}),
),
),
);
};
// Main render.
return wp.element.createElement(
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" },
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);