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.
11793 lines
406 KiB
JavaScript
11793 lines
406 KiB
JavaScript
/**
|
||
* 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(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/&/g, "&")
|
||
.replace(/"/g, '"');
|
||
}
|
||
}
|
||
|
||
// Handle table blocks - extract head and body from innerHTML
|
||
if (block.blockName === "core/table" && block.innerHTML) {
|
||
const headMatch = block.innerHTML.match(/<thead>([\s\S]*?)<\/thead>/i);
|
||
const bodyMatch = block.innerHTML.match(/<tbody>([\s\S]*?)<\/tbody>/i);
|
||
if (headMatch || bodyMatch) {
|
||
attrs.head = [];
|
||
attrs.body = [];
|
||
|
||
// Parse thead rows
|
||
if (headMatch) {
|
||
const headRows = headMatch[1].match(/<tr>([\s\S]*?)<\/tr>/gi) || [];
|
||
headRows.forEach((row) => {
|
||
const cells = [];
|
||
const cellMatches =
|
||
row.match(/<t[hd]>([\s\S]*?)<\/t[hd]>/gi) || [];
|
||
cellMatches.forEach((cell) => {
|
||
const content = cell.replace(/<\/?t[hd]>/gi, "");
|
||
cells.push({ content, tag: "th" });
|
||
});
|
||
if (cells.length > 0) attrs.head.push({ cells });
|
||
});
|
||
}
|
||
|
||
// Parse tbody rows
|
||
if (bodyMatch) {
|
||
const bodyRows = bodyMatch[1].match(/<tr>([\s\S]*?)<\/tr>/gi) || [];
|
||
bodyRows.forEach((row) => {
|
||
const cells = [];
|
||
const cellMatches = row.match(/<td>([\s\S]*?)<\/td>/gi) || [];
|
||
cellMatches.forEach((cell) => {
|
||
const content = cell.replace(/<\/?td>/gi, "");
|
||
cells.push({ content, tag: "td" });
|
||
});
|
||
if (cells.length > 0) attrs.body.push({ cells });
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle button blocks from [CTA:...] syntax
|
||
if (
|
||
block.blockName === "core/buttons" ||
|
||
block.blockName === "core/button"
|
||
) {
|
||
if (block.blockName === "core/button") {
|
||
return wp.blocks.createBlock("core/buttons", {}, [
|
||
wp.blocks.createBlock("core/button", attrs),
|
||
]);
|
||
}
|
||
}
|
||
|
||
if (block.innerBlocks && block.innerBlocks.length > 0) {
|
||
const innerBlocks = block.innerBlocks
|
||
.map((innerBlock) => createBlocksFromSerialized(innerBlock))
|
||
.filter(Boolean);
|
||
return wp.blocks.createBlock(block.blockName, attrs, innerBlocks);
|
||
}
|
||
|
||
return wp.blocks.createBlock(block.blockName, attrs);
|
||
};
|
||
const reformatBlocks = async (blocksToReformat, originalMessage) => {
|
||
if (isLoading) {
|
||
return;
|
||
}
|
||
|
||
if (!blocksToReformat || blocksToReformat.length === 0) {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
role: "system",
|
||
type: "error",
|
||
content: "No blocks found to reformat.",
|
||
},
|
||
]);
|
||
return;
|
||
}
|
||
|
||
setIsLoading(true);
|
||
setMessages((prev) => [
|
||
...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: "system",
|
||
type: "timeline",
|
||
status: "refining",
|
||
message: `Reformatting ${blocksToReformat.length} block(s)...`,
|
||
timestamp: new Date(),
|
||
},
|
||
]);
|
||
|
||
try {
|
||
const response = await fetch(
|
||
wpAgenticWriter.apiUrl + "/reformat-blocks",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-WP-Nonce": wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({
|
||
blocks: blocksToReformat,
|
||
postId: postId,
|
||
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: "Don’t ask again for this session",
|
||
checked: refineAllConfirm.dontAskAgain,
|
||
onChange: (checked) => {
|
||
setRefineAllConfirm((prev) => ({
|
||
...prev,
|
||
dontAskAgain: Boolean(checked),
|
||
}));
|
||
},
|
||
}),
|
||
wp.element.createElement(
|
||
"div",
|
||
{ className: "wpaw-refine-confirm-actions" },
|
||
wp.element.createElement(
|
||
Button,
|
||
{
|
||
isSecondary: true,
|
||
onClick: () => resolveRefineAllConfirmation(false),
|
||
},
|
||
"Cancel",
|
||
),
|
||
wp.element.createElement(
|
||
Button,
|
||
{
|
||
isPrimary: true,
|
||
onClick: () => {
|
||
if (refineAllConfirm.dontAskAgain) {
|
||
skipRefineAllConfirmRef.current = true;
|
||
}
|
||
resolveRefineAllConfirmation(true);
|
||
},
|
||
},
|
||
"Continue",
|
||
),
|
||
),
|
||
),
|
||
);
|
||
};
|
||
|
||
// Get mention options for autocomplete
|
||
const getMentionOptions = (query) => {
|
||
const allBlocks = select("core/block-editor").getBlocks();
|
||
const selectedBlockId =
|
||
select("core/block-editor").getSelectedBlockClientId();
|
||
const options = [];
|
||
|
||
// Add special mentions
|
||
if (!query || "this".includes(query.toLowerCase())) {
|
||
options.push({
|
||
id: "this",
|
||
label: "@this",
|
||
sublabel: "Currently selected block",
|
||
type: "special",
|
||
});
|
||
}
|
||
if (!query || "previous".includes(query.toLowerCase())) {
|
||
options.push({
|
||
id: "previous",
|
||
label: "@previous",
|
||
sublabel: "Block before current selection",
|
||
type: "special",
|
||
});
|
||
}
|
||
if (!query || "next".includes(query.toLowerCase())) {
|
||
options.push({
|
||
id: "next",
|
||
label: "@next",
|
||
sublabel: "Block after current selection",
|
||
type: "special",
|
||
});
|
||
}
|
||
if (!query || "all".includes(query.toLowerCase())) {
|
||
options.push({
|
||
id: "all",
|
||
label: "@all",
|
||
sublabel: "All content blocks",
|
||
type: "special",
|
||
});
|
||
}
|
||
if (!query || "title".includes(query.toLowerCase())) {
|
||
options.push({
|
||
id: "title",
|
||
label: "@title",
|
||
sublabel: "Refine post title with instruction",
|
||
type: "special",
|
||
});
|
||
}
|
||
|
||
// Add numbered blocks for core blocks
|
||
const blockCounters = {};
|
||
const queryLower = query.toLowerCase();
|
||
let listItemIndex = 0;
|
||
let listBlockIndex = 0;
|
||
|
||
allBlocks.forEach((block) => {
|
||
if (!block.name || !block.name.startsWith("core/")) {
|
||
return;
|
||
}
|
||
|
||
const typeName = block.name.replace("core/", "");
|
||
blockCounters[typeName] = (blockCounters[typeName] || 0) + 1;
|
||
const blockLabel = `@${typeName}-${blockCounters[typeName]}`;
|
||
|
||
const content = extractBlockPreview(block);
|
||
const contentLower = content.toLowerCase();
|
||
if (
|
||
!query ||
|
||
blockLabel.includes(queryLower) ||
|
||
contentLower.startsWith(queryLower)
|
||
) {
|
||
const truncatedContent =
|
||
content.length > 40 ? content.substring(0, 40) + "..." : content;
|
||
options.push({
|
||
id: blockLabel,
|
||
label: String(blockLabel),
|
||
sublabel: truncatedContent || String(typeName),
|
||
type: "block",
|
||
clientId: block.clientId,
|
||
});
|
||
}
|
||
|
||
if (block.name === "core/list") {
|
||
listBlockIndex += 1;
|
||
const innerItems = Array.isArray(block.innerBlocks)
|
||
? block.innerBlocks
|
||
: [];
|
||
innerItems.forEach((itemBlock, itemIndex) => {
|
||
if (itemBlock.name !== "core/list-item") {
|
||
return;
|
||
}
|
||
|
||
listItemIndex += 1;
|
||
const itemLabel = `@listitem-${listItemIndex}`;
|
||
const explicitLabel = `@list-${listBlockIndex}.list-item-${itemIndex}`;
|
||
const itemContent = extractBlockPreview(itemBlock);
|
||
const itemLower = itemContent.toLowerCase();
|
||
if (
|
||
!query ||
|
||
itemLabel.includes(queryLower) ||
|
||
explicitLabel.includes(queryLower) ||
|
||
itemLower.startsWith(queryLower)
|
||
) {
|
||
const truncatedItem =
|
||
itemContent.length > 40
|
||
? itemContent.substring(0, 40) + "..."
|
||
: itemContent;
|
||
options.push({
|
||
id: itemLabel,
|
||
label: String(explicitLabel),
|
||
sublabel: truncatedItem
|
||
? `List ${listBlockIndex}: ${truncatedItem}`
|
||
: `List ${listBlockIndex} item`,
|
||
type: "list-item",
|
||
clientId: itemBlock.clientId,
|
||
parentClientId: block.clientId,
|
||
});
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
return options;
|
||
};
|
||
|
||
React.useEffect(() => {
|
||
const handleInsertMention = (event) => {
|
||
const token = event?.detail?.token;
|
||
if (!token) {
|
||
return;
|
||
}
|
||
|
||
setActiveTab("chat");
|
||
setInput((prev) => {
|
||
const prefix = prev && !/\s$/.test(prev) ? prev + " " : prev;
|
||
return `${prefix}${token}`;
|
||
});
|
||
|
||
setTimeout(() => {
|
||
const inputNode = inputRef.current?.textarea || inputRef.current;
|
||
if (inputNode) {
|
||
inputNode.focus();
|
||
inputNode.selectionStart = inputNode.selectionEnd =
|
||
inputNode.value.length;
|
||
}
|
||
|
||
const mentionOptionsList = getMentionOptions("");
|
||
setMentionOptions(mentionOptionsList);
|
||
setShowMentionAutocomplete(mentionOptionsList.length > 0);
|
||
}, 0);
|
||
};
|
||
|
||
window.addEventListener("wpaw:insert-mention", handleInsertMention);
|
||
return () =>
|
||
window.removeEventListener("wpaw:insert-mention", handleInsertMention);
|
||
}, [getMentionOptions]);
|
||
|
||
// Handle input change for mention detection
|
||
const handleInputChange = (value) => {
|
||
setInput(value);
|
||
|
||
// Check if user is typing a mention
|
||
const inputNode = inputRef.current?.textarea || inputRef.current;
|
||
const cursorPosition =
|
||
typeof inputNode?.selectionStart === "number"
|
||
? inputNode.selectionStart
|
||
: value.length;
|
||
const textBeforeCursor = value.substring(0, cursorPosition);
|
||
const mentionMatch = textBeforeCursor.match(/@(\w*)$/);
|
||
const slashMatch = textBeforeCursor.match(/\/([\w\s]*)$/);
|
||
|
||
if (mentionMatch) {
|
||
const query = mentionMatch[1];
|
||
setMentionQuery(query);
|
||
const options = getMentionOptions(query);
|
||
setMentionOptions(options);
|
||
setShowMentionAutocomplete(options.length > 0);
|
||
setMentionCursorIndex(0);
|
||
setShowSlashAutocomplete(false);
|
||
setSlashOptions([]);
|
||
} else if (slashMatch) {
|
||
const query = slashMatch[1];
|
||
setSlashQuery(query);
|
||
const options = getSlashOptions(query);
|
||
setSlashOptions(options);
|
||
setShowSlashAutocomplete(options.length > 0);
|
||
setSlashCursorIndex(0);
|
||
setShowMentionAutocomplete(false);
|
||
setMentionOptions([]);
|
||
} else {
|
||
setShowMentionAutocomplete(false);
|
||
setMentionOptions([]);
|
||
setShowSlashAutocomplete(false);
|
||
setSlashOptions([]);
|
||
}
|
||
};
|
||
|
||
// Handle keyboard navigation in autocomplete
|
||
const handleKeyDown = (e) => {
|
||
if (!showMentionAutocomplete && !showSlashAutocomplete) {
|
||
if (e.keyCode === 13 && (e.metaKey || e.ctrlKey)) {
|
||
sendMessage();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (showMentionAutocomplete && e.keyCode === 40) {
|
||
// Down arrow
|
||
e.preventDefault();
|
||
setMentionCursorIndex((prev) => (prev + 1) % mentionOptions.length);
|
||
} else if (showMentionAutocomplete && e.keyCode === 38) {
|
||
// Up arrow
|
||
e.preventDefault();
|
||
setMentionCursorIndex(
|
||
(prev) => (prev - 1 + mentionOptions.length) % mentionOptions.length,
|
||
);
|
||
} else if (showMentionAutocomplete && e.keyCode === 13) {
|
||
// Enter
|
||
e.preventDefault();
|
||
if (mentionOptions[mentionCursorIndex]) {
|
||
insertMention(mentionOptions[mentionCursorIndex]);
|
||
}
|
||
} else if (showSlashAutocomplete && e.keyCode === 40) {
|
||
// Down arrow
|
||
e.preventDefault();
|
||
setSlashCursorIndex((prev) => (prev + 1) % slashOptions.length);
|
||
} else if (showSlashAutocomplete && e.keyCode === 38) {
|
||
// Up arrow
|
||
e.preventDefault();
|
||
setSlashCursorIndex(
|
||
(prev) => (prev - 1 + slashOptions.length) % slashOptions.length,
|
||
);
|
||
} else if (showSlashAutocomplete && e.keyCode === 13) {
|
||
// Enter
|
||
e.preventDefault();
|
||
if (slashOptions[slashCursorIndex]) {
|
||
insertSlashCommand(slashOptions[slashCursorIndex]);
|
||
}
|
||
} else if (e.keyCode === 27) {
|
||
// Escape
|
||
e.preventDefault();
|
||
setShowMentionAutocomplete(false);
|
||
setShowSlashAutocomplete(false);
|
||
}
|
||
};
|
||
|
||
// Insert selected mention
|
||
const insertMention = (option) => {
|
||
const value = input;
|
||
const inputNode = inputRef.current?.textarea || inputRef.current;
|
||
const cursorPosition =
|
||
typeof inputNode?.selectionStart === "number"
|
||
? inputNode.selectionStart
|
||
: value.length;
|
||
const textBeforeCursor = value.substring(0, cursorPosition);
|
||
const mentionStart = textBeforeCursor.lastIndexOf("@");
|
||
|
||
const beforeMention = value.substring(0, mentionStart);
|
||
const afterMention = value.substring(cursorPosition);
|
||
const newValue = beforeMention + option.label + " " + afterMention;
|
||
|
||
setInput(newValue);
|
||
setShowMentionAutocomplete(false);
|
||
setMentionOptions([]);
|
||
|
||
// Focus back on input
|
||
setTimeout(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus();
|
||
}
|
||
}, 0);
|
||
};
|
||
const insertSlashCommand = (option) => {
|
||
const value = input;
|
||
const inputNode = inputRef.current?.textarea || inputRef.current;
|
||
const cursorPosition =
|
||
typeof inputNode?.selectionStart === "number"
|
||
? inputNode.selectionStart
|
||
: value.length;
|
||
const textBeforeCursor = value.substring(0, cursorPosition);
|
||
const slashStart = textBeforeCursor.lastIndexOf("/");
|
||
|
||
const beforeSlash = value.substring(0, slashStart);
|
||
const afterSlash = value.substring(cursorPosition);
|
||
const newValue = beforeSlash + option.insertText + afterSlash;
|
||
|
||
setInput(newValue);
|
||
setShowSlashAutocomplete(false);
|
||
setSlashOptions([]);
|
||
if (option.insertText.endsWith("@")) {
|
||
const mentionOptionsList = getMentionOptions("");
|
||
setMentionQuery("");
|
||
setMentionOptions(mentionOptionsList);
|
||
setShowMentionAutocomplete(mentionOptionsList.length > 0);
|
||
setMentionCursorIndex(0);
|
||
}
|
||
|
||
setTimeout(() => {
|
||
if (inputRef.current) {
|
||
inputRef.current.focus();
|
||
}
|
||
}, 0);
|
||
};
|
||
|
||
const sendMessage = async () => {
|
||
if (!input.trim() || isLoading) {
|
||
return;
|
||
}
|
||
|
||
const userMessage = input.trim();
|
||
// Collapse textarea to give more space for response
|
||
setIsTextareaExpanded(false);
|
||
|
||
// Check for reset command
|
||
if (/^\s*(\/reset|\/clear)\s*$/i.test(userMessage)) {
|
||
setInput("");
|
||
await handleResetCommand();
|
||
return;
|
||
}
|
||
|
||
const agentRoute = decideAgentAction(userMessage);
|
||
const effectiveAgentMode = agentRoute.mode || agentMode || "chat";
|
||
if (
|
||
["chat", "planning", "writing"].includes(effectiveAgentMode) &&
|
||
effectiveAgentMode !== agentMode
|
||
) {
|
||
setAgentMode(effectiveAgentMode);
|
||
}
|
||
|
||
if (agentRoute.action === "execute_plan") {
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
addActivityTimeline(
|
||
"checking",
|
||
"Checking outline and editor context...",
|
||
);
|
||
await executePlanFromCard({ skipConfirm: true });
|
||
return;
|
||
}
|
||
|
||
if (agentRoute.action === "generate_meta") {
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
addActivityTimeline(
|
||
"checking",
|
||
"Reading article and generating meta description...",
|
||
);
|
||
await generateMetaDescription();
|
||
return;
|
||
}
|
||
|
||
if (agentRoute.action === "seo_audit") {
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
addActivityTimeline(
|
||
"checking",
|
||
"Reading editor and running SEO audit...",
|
||
);
|
||
await runSeoAudit();
|
||
return;
|
||
}
|
||
|
||
const parsedCommand = parseInsertCommand(userMessage);
|
||
const commandMessage = parsedCommand
|
||
? parsedCommand.message
|
||
: userMessage;
|
||
const mentionTokens = extractMentionsFromText(commandMessage);
|
||
const hasMentions = mentionTokens.length > 0;
|
||
const titleMentioned = hasTitleMention(mentionTokens);
|
||
const refineableBlocks = getRefineableBlocks();
|
||
const shouldShowPlan = effectiveAgentMode === "planning";
|
||
const generationLabel =
|
||
effectiveAgentMode === "planning"
|
||
? "Creating outline..."
|
||
: "Generating article...";
|
||
const reformatCommand = /^\s*(?:\/)?reformat\b/i;
|
||
|
||
if (parsedCommand) {
|
||
setIsLoading(true);
|
||
setMessages((prev) => [
|
||
...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: "system",
|
||
type: "timeline",
|
||
status: "refining",
|
||
message: "Preparing insertion...",
|
||
timestamp: new Date(),
|
||
},
|
||
]);
|
||
await insertRefinementBlock(
|
||
parsedCommand.mode,
|
||
commandMessage,
|
||
mentionTokens,
|
||
userMessage,
|
||
);
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
if (reformatCommand.test(userMessage)) {
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
const targetIds = hasMentions
|
||
? resolveBlockMentions(mentionTokens)
|
||
: getRefineableBlocks().map((block) => block.clientId);
|
||
const allBlocks = select("core/block-editor").getBlocks();
|
||
const blocksToReformat = allBlocks.filter((block) =>
|
||
targetIds.includes(block.clientId),
|
||
);
|
||
await reformatBlocks(blocksToReformat, userMessage);
|
||
return;
|
||
}
|
||
|
||
if (titleMentioned) {
|
||
setInput("");
|
||
await handleTitleRefinement(userMessage, mentionTokens);
|
||
return;
|
||
}
|
||
|
||
if (
|
||
effectiveAgentMode === "planning" &&
|
||
!hasMentions &&
|
||
currentPlanRef.current
|
||
) {
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
await revisePlanFromPrompt(userMessage);
|
||
return;
|
||
}
|
||
|
||
if (effectiveAgentMode === "chat" && !hasMentions) {
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
const operationController = beginAgentOperation(
|
||
"chat",
|
||
"chat response",
|
||
);
|
||
setIsLoading(true);
|
||
|
||
// User message is NOT an AI suggestion - don't extract from user input
|
||
|
||
// Store for retry
|
||
lastChatRequestRef.current = { message: userMessage };
|
||
|
||
try {
|
||
const chatHistory = messages
|
||
.filter((m) => m.role === "user" || m.role === "assistant")
|
||
.map((m) => ({ role: m.role, content: m.content }));
|
||
|
||
const response = await fetch(wpAgenticWriter.apiUrl + "/chat", {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-WP-Nonce": wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({
|
||
messages: [
|
||
...chatHistory,
|
||
{ role: "user", content: userMessage },
|
||
],
|
||
postId: postId,
|
||
sessionId: currentSessionId,
|
||
type: "chat",
|
||
stream: true,
|
||
postConfig: postConfig,
|
||
}),
|
||
signal: operationController.signal,
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
throw new Error(error.message || "Failed to chat");
|
||
}
|
||
|
||
const reader = registerActiveReader(response.body.getReader());
|
||
const decoder = new TextDecoder();
|
||
let streamBuffer = "";
|
||
let streamError = null;
|
||
streamTargetRef.current = null;
|
||
|
||
while (true) {
|
||
if (
|
||
stopExecutionRef.current ||
|
||
operationController.signal.aborted
|
||
) {
|
||
await reader.cancel().catch(() => {});
|
||
throw new DOMException("Operation stopped by user", "AbortError");
|
||
}
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
streamBuffer += decoder.decode(value, { stream: true });
|
||
const lines = streamBuffer.split("\n");
|
||
streamBuffer = lines.pop() || "";
|
||
|
||
for (const line of lines) {
|
||
if (!line.startsWith("data: ")) {
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
if (data.type === "error") {
|
||
streamError = new Error(data.message || "Failed to chat");
|
||
break;
|
||
}
|
||
if (
|
||
data.type === "conversational" ||
|
||
data.type === "conversational_stream"
|
||
) {
|
||
const cleanContent = (data.content || "").trim();
|
||
if (!cleanContent) {
|
||
continue;
|
||
}
|
||
|
||
const streamTarget =
|
||
streamTargetRef.current ||
|
||
resolveStreamTarget(cleanContent);
|
||
if (!streamTarget) {
|
||
continue;
|
||
}
|
||
|
||
streamTargetRef.current = streamTarget;
|
||
|
||
if (data.type === "conversational") {
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
const lastIdx = newMessages.length - 1;
|
||
const lastMessage = newMessages[lastIdx];
|
||
if (
|
||
lastMessage &&
|
||
lastMessage.role === "assistant" &&
|
||
lastMessage.content === cleanContent
|
||
) {
|
||
return newMessages;
|
||
}
|
||
newMessages.push({
|
||
role: "assistant",
|
||
content: cleanContent,
|
||
});
|
||
return newMessages;
|
||
});
|
||
} else {
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
const lastIdx = newMessages.length - 1;
|
||
if (
|
||
newMessages[lastIdx] &&
|
||
newMessages[lastIdx].role === "assistant"
|
||
) {
|
||
newMessages[lastIdx] = {
|
||
...newMessages[lastIdx],
|
||
content: cleanContent,
|
||
};
|
||
} else {
|
||
newMessages.push({
|
||
role: "assistant",
|
||
content: cleanContent,
|
||
});
|
||
}
|
||
return newMessages;
|
||
});
|
||
}
|
||
} else if (data.type === "complete") {
|
||
if (data.totalCost) {
|
||
setCost({
|
||
...cost,
|
||
session: cost.session + data.totalCost,
|
||
});
|
||
}
|
||
applyProviderMetadata(data);
|
||
// Extract ALL focus keyword suggestions from AI response
|
||
setMessages((prev) => {
|
||
const lastAssistantMsg = prev
|
||
.filter((m) => m.role === "assistant")
|
||
.pop();
|
||
if (lastAssistantMsg && lastAssistantMsg.content) {
|
||
const suggestions = extractFocusKeywordSuggestions(
|
||
lastAssistantMsg.content,
|
||
);
|
||
if (suggestions.length > 0) {
|
||
addFocusKeywordSuggestions(suggestions);
|
||
}
|
||
}
|
||
return prev;
|
||
});
|
||
}
|
||
} catch (parseError) {
|
||
wpawLog.error(
|
||
"Failed to parse streaming data:",
|
||
line,
|
||
parseError,
|
||
);
|
||
}
|
||
}
|
||
|
||
if (streamError) {
|
||
throw streamError;
|
||
}
|
||
}
|
||
|
||
// Detect intent after chat completes
|
||
try {
|
||
const intentResult = await detectUserIntent(userMessage);
|
||
|
||
// Track intent detection cost
|
||
if (intentResult.cost > 0) {
|
||
setCost((prev) => ({
|
||
...prev,
|
||
session: prev.session + intentResult.cost,
|
||
}));
|
||
}
|
||
|
||
if (
|
||
intentResult.intent &&
|
||
intentResult.intent !== "continue_chat"
|
||
) {
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
const lastIdx = newMessages.length - 1;
|
||
if (
|
||
newMessages[lastIdx] &&
|
||
newMessages[lastIdx].role === "assistant"
|
||
) {
|
||
newMessages[lastIdx] = {
|
||
...newMessages[lastIdx],
|
||
detectedIntent: intentResult.intent,
|
||
};
|
||
}
|
||
return newMessages;
|
||
});
|
||
}
|
||
} catch (intentError) {
|
||
wpawLog.error(
|
||
"Intent detection failed:",
|
||
formatAiErrorMessage(intentError, "Intent detection failed"),
|
||
);
|
||
}
|
||
} catch (error) {
|
||
if (isAbortError(error)) {
|
||
setMessages((prev) => [
|
||
...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: "system",
|
||
type: "timeline",
|
||
status: "stopped",
|
||
message: "Chat response stopped.",
|
||
timestamp: new Date(),
|
||
},
|
||
]);
|
||
// Continue to shared cleanup below.
|
||
} else {
|
||
const errorMsg = formatAiErrorMessage(error, "Failed to chat");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
role: "system",
|
||
type: "error",
|
||
content: errorMsg,
|
||
canRetry: true,
|
||
retryType: "chat",
|
||
},
|
||
]);
|
||
}
|
||
}
|
||
|
||
setIsLoading(false);
|
||
finishAgentOperation("chat");
|
||
return;
|
||
}
|
||
|
||
if (
|
||
!hasMentions &&
|
||
refineableBlocks.length > 0 &&
|
||
agentRoute.action === "article_refinement"
|
||
) {
|
||
// Content exists - run clarity check before full-article refinement
|
||
const targetedBlocks = getTargetedRefinementBlocks(userMessage);
|
||
const matchedSection = !targetedBlocks
|
||
? findBestPlanSectionMatch(userMessage)
|
||
: null;
|
||
const matchedSectionBlocks = matchedSection
|
||
? sectionBlocksRef.current[matchedSection.id] || []
|
||
: [];
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
if (matchedSectionBlocks.length > 0) {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
role: "assistant",
|
||
content: `Targeting section: ${matchedSection.heading || matchedSection.title || "Selected section"} (${matchedSectionBlocks.length} block(s)).`,
|
||
},
|
||
]);
|
||
}
|
||
setIsLoading(true);
|
||
setMessages((prev) => [
|
||
...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: "system",
|
||
type: "timeline",
|
||
status: "checking",
|
||
message: matchedSection
|
||
? `Analyzing request (targeting: ${matchedSection.heading || matchedSection.title || "section"})...`
|
||
: "Analyzing request...",
|
||
timestamp: new Date(),
|
||
},
|
||
]);
|
||
|
||
const fallbackBlocks = isAiSlopRequest(userMessage)
|
||
? selectLikelyAiSlopBlocks(userMessage, refineableBlocks).map(
|
||
(block) => block.clientId,
|
||
)
|
||
: refineableBlocks.map((block) => block.clientId);
|
||
if (
|
||
isAiSlopRequest(userMessage) &&
|
||
fallbackBlocks.length === 0 &&
|
||
!targetedBlocks &&
|
||
matchedSectionBlocks.length === 0
|
||
) {
|
||
setMessages((prev) => [
|
||
...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: "assistant",
|
||
content:
|
||
"I inspected the article and did not find blocks matching the AI-ish/slop detector, so I did not send the whole article to refinement.",
|
||
},
|
||
]);
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
await handleChatRefinement(
|
||
userMessage,
|
||
targetedBlocks && targetedBlocks.length > 0
|
||
? targetedBlocks
|
||
: matchedSectionBlocks.length > 0
|
||
? matchedSectionBlocks
|
||
: fallbackBlocks,
|
||
{ skipUserMessage: true },
|
||
);
|
||
return;
|
||
}
|
||
|
||
if (!hasMentions) {
|
||
// No mentions - check clarity first before article generation
|
||
setInput("");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "user", content: userMessage },
|
||
]);
|
||
const operationType =
|
||
effectiveAgentMode === "planning" ? "planning" : "generation";
|
||
const operationController = beginAgentOperation(
|
||
operationType,
|
||
effectiveAgentMode === "planning"
|
||
? "outline generation"
|
||
: "article generation",
|
||
);
|
||
setIsLoading(true);
|
||
|
||
// Check clarity first
|
||
setMessages((prev) => [
|
||
...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: "system",
|
||
type: "timeline",
|
||
status: "checking",
|
||
message: "Analyzing request...",
|
||
timestamp: new Date(),
|
||
},
|
||
]);
|
||
|
||
// First try clarity check
|
||
let requestDetectedLanguage = detectedLanguage;
|
||
try {
|
||
const clarityResponse = await fetch(
|
||
wpAgenticWriter.apiUrl + "/check-clarity",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-WP-Nonce": wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({
|
||
topic: userMessage,
|
||
answers: [],
|
||
postId: postId,
|
||
sessionId: currentSessionId,
|
||
mode: "generation",
|
||
postConfig: postConfig,
|
||
chatHistory: buildChatHistoryPayload(),
|
||
}),
|
||
signal: operationController.signal,
|
||
},
|
||
);
|
||
|
||
if (clarityResponse.ok) {
|
||
const clarityData = await clarityResponse.json();
|
||
const clarityResult = clarityData.result;
|
||
|
||
// Store detected language for article generation
|
||
if (clarityResult.detected_language) {
|
||
requestDetectedLanguage = clarityResult.detected_language;
|
||
setDetectedLanguage(clarityResult.detected_language);
|
||
}
|
||
|
||
if (
|
||
!clarityResult.is_clear &&
|
||
clarityResult.questions &&
|
||
clarityResult.questions.length > 0
|
||
) {
|
||
// Need clarification - show quiz
|
||
setQuestions(clarityResult.questions);
|
||
setInClarification(true);
|
||
setCurrentQuestionIndex(0);
|
||
setAnswers([]);
|
||
setIsLoading(false);
|
||
|
||
// Update timeline
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
const lastTimelineIndex =
|
||
findLastActiveTimelineIndex(newMessages);
|
||
if (lastTimelineIndex !== -1) {
|
||
newMessages[lastTimelineIndex] = {
|
||
...newMessages[lastTimelineIndex],
|
||
status: "waiting",
|
||
message: "Waiting for clarification...",
|
||
};
|
||
}
|
||
return newMessages;
|
||
});
|
||
finishAgentOperation(operationType);
|
||
return;
|
||
}
|
||
}
|
||
// If clarity check fails, proceed with generation anyway
|
||
} catch (clarityError) {
|
||
if (isAbortError(clarityError)) {
|
||
setMessages((prev) => [
|
||
...deactivateActiveTimelineEntries(prev),
|
||
{
|
||
role: "system",
|
||
type: "timeline",
|
||
status: "stopped",
|
||
message: "Generation stopped.",
|
||
timestamp: new Date(),
|
||
},
|
||
]);
|
||
setIsLoading(false);
|
||
finishAgentOperation(operationType);
|
||
return;
|
||
}
|
||
wpawLog.warn(
|
||
"Clarity check failed, proceeding with generation:",
|
||
clarityError,
|
||
);
|
||
// Continue to article generation
|
||
}
|
||
|
||
// Clear enough - proceed with article generation
|
||
// Update timeline
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
const lastTimelineIndex = findLastActiveTimelineIndex(newMessages);
|
||
if (lastTimelineIndex !== -1) {
|
||
newMessages[lastTimelineIndex] = {
|
||
...newMessages[lastTimelineIndex],
|
||
status: "starting",
|
||
message: generationLabel,
|
||
};
|
||
}
|
||
return newMessages;
|
||
});
|
||
|
||
// Now call generate-plan
|
||
let timeout = null;
|
||
try {
|
||
const response = await fetch(
|
||
wpAgenticWriter.apiUrl + "/generate-plan",
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
"X-WP-Nonce": wpAgenticWriter.nonce,
|
||
},
|
||
body: JSON.stringify({
|
||
topic: userMessage,
|
||
context: "",
|
||
postId: postId,
|
||
sessionId: currentSessionId,
|
||
answers: [],
|
||
autoExecute: effectiveAgentMode !== "planning",
|
||
stream: true,
|
||
articleLength: postConfig.article_length,
|
||
detectedLanguage: requestDetectedLanguage,
|
||
postConfig: postConfig,
|
||
chatHistory: buildChatHistoryPayload(),
|
||
}),
|
||
signal: operationController.signal,
|
||
},
|
||
);
|
||
|
||
if (!response.ok) {
|
||
const error = await response.json();
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
role: "system",
|
||
type: "error",
|
||
content: formatAiErrorMessage(
|
||
error,
|
||
"Failed to generate article",
|
||
),
|
||
canRetry: true,
|
||
retryType: "generation",
|
||
},
|
||
]);
|
||
setIsLoading(false);
|
||
return;
|
||
}
|
||
|
||
// Handle streaming response
|
||
streamTargetRef.current = null;
|
||
const reader = registerActiveReader(response.body.getReader());
|
||
const decoder = new TextDecoder();
|
||
|
||
// Add timeout to detect hanging responses
|
||
timeout = setTimeout(() => {
|
||
if (isLoading) {
|
||
wpawLog.error("Generation timeout - no response received");
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{
|
||
role: "system",
|
||
type: "error",
|
||
content: formatAiErrorMessage(
|
||
"cURL error 28: Operation timed out after 120000 milliseconds",
|
||
"Failed to generate article",
|
||
),
|
||
canRetry: true,
|
||
retryType: "generation",
|
||
},
|
||
]);
|
||
setIsLoading(false);
|
||
reader.cancel();
|
||
}
|
||
}, 120000); // 2 minute timeout
|
||
|
||
while (true) {
|
||
if (
|
||
stopExecutionRef.current ||
|
||
operationController.signal.aborted
|
||
) {
|
||
await reader.cancel().catch(() => {});
|
||
throw new DOMException("Operation stopped by user", "AbortError");
|
||
}
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
const lines = chunk.split("\n");
|
||
|
||
for (const line of lines) {
|
||
if (line.startsWith("data: ")) {
|
||
try {
|
||
const data = JSON.parse(line.slice(6));
|
||
|
||
if (data.type === "plan") {
|
||
setCost({ ...cost, session: cost.session + data.cost });
|
||
if (shouldShowPlan && data.plan) {
|
||
updateOrCreatePlanMessage(data.plan, {
|
||
suggestKeywords: effectiveAgentMode === "planning",
|
||
});
|
||
}
|
||
} else if (data.type === "title_update") {
|
||
dispatch("core/editor").editPost({ title: data.title });
|
||
} else if (data.type === "status") {
|
||
if (data.status === "complete") {
|
||
continue;
|
||
}
|
||
|
||
// Update timeline
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
const lastTimelineIndex =
|
||
findLastActiveTimelineIndex(newMessages);
|
||
if (lastTimelineIndex !== -1) {
|
||
newMessages[lastTimelineIndex] = {
|
||
...newMessages[lastTimelineIndex],
|
||
status: data.status,
|
||
message: data.message,
|
||
icon: data.icon,
|
||
};
|
||
}
|
||
return newMessages;
|
||
});
|
||
} else if (
|
||
data.type === "conversational" ||
|
||
data.type === "conversational_stream"
|
||
) {
|
||
// Remove article marker and clean content
|
||
const cleanContent = (data.content || "")
|
||
.replace(/~~~ARTICLE~+/g, "")
|
||
.replace(/~~~ARTICLE~~~[\r\n]*/g, "")
|
||
.trim();
|
||
|
||
// Skip if content is empty after cleaning
|
||
if (
|
||
!cleanContent ||
|
||
shouldSkipPlanningCompletion(cleanContent)
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
const streamTarget =
|
||
streamTargetRef.current ||
|
||
resolveStreamTarget(cleanContent);
|
||
|
||
if (!streamTarget) {
|
||
continue;
|
||
}
|
||
|
||
streamTargetRef.current = streamTarget;
|
||
|
||
if (streamTarget === "timeline") {
|
||
updateOrCreateTimelineEntry(cleanContent);
|
||
} else {
|
||
// This is actual conversational content - add as chat bubble
|
||
if (data.type === "conversational") {
|
||
setMessages((prev) => [
|
||
...prev,
|
||
{ role: "assistant", content: cleanContent },
|
||
]);
|
||
} else {
|
||
setMessages((prev) => {
|
||
const newMessages = [...prev];
|
||
const lastIdx = newMessages.length - 1;
|
||
if (
|
||
newMessages[lastIdx] &&
|
||
newMessages[lastIdx].role === "assistant"
|
||
) {
|
||
newMessages[lastIdx] = {
|
||
...newMessages[lastIdx],
|
||
content: cleanContent,
|
||
};
|
||
} else {
|
||
newMessages.push({
|
||
role: "assistant",
|
||
content: cleanContent,
|
||
});
|
||
}
|
||
return newMessages;
|
||
});
|
||
}
|
||
}
|
||
} else if (data.type === "block") {
|
||
const { insertBlocks } = dispatch("core/block-editor");
|
||
let newBlock;
|
||
|
||
if (data.block.blockName === "core/paragraph") {
|
||
const content =
|
||
data.block.innerHTML?.match(/<p>(.*?)<\/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, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
};
|
||
const inlineMarkdownToHtml = (text) => {
|
||
let html = escapeHtml(text);
|
||
html = html.replace(
|
||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||
(match, label, url) =>
|
||
`<a href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${label}</a>`,
|
||
);
|
||
html = html.replace(
|
||
/`([^`]+)`/g,
|
||
(match, code) => `<code>${escapeHtml(code)}</code>`,
|
||
);
|
||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||
html = html.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
||
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||
html = html.replace(/_([^_]+)_/g, "<em>$1</em>");
|
||
return html;
|
||
};
|
||
const markdownToHtml = (markdown) => {
|
||
const raw = normalizeMessageContent(markdown);
|
||
if (!raw) {
|
||
return "";
|
||
}
|
||
|
||
if (window.markdownit && window.DOMPurify) {
|
||
if (!markdownRendererRef.current) {
|
||
const renderer = window.markdownit({
|
||
html: false,
|
||
linkify: true,
|
||
breaks: false,
|
||
});
|
||
if (window.markdownitTaskLists) {
|
||
renderer.use(window.markdownitTaskLists, {
|
||
enabled: true,
|
||
label: true,
|
||
labelAfter: true,
|
||
});
|
||
}
|
||
const defaultLinkOpen =
|
||
renderer.renderer.rules.link_open ||
|
||
function (tokens, idx, options, env, self) {
|
||
return self.renderToken(tokens, idx, options);
|
||
};
|
||
renderer.renderer.rules.link_open = function (
|
||
tokens,
|
||
idx,
|
||
options,
|
||
env,
|
||
self,
|
||
) {
|
||
const token = tokens[idx];
|
||
const targetIndex = token.attrIndex("target");
|
||
if (targetIndex < 0) {
|
||
token.attrPush(["target", "_blank"]);
|
||
} else {
|
||
token.attrs[targetIndex][1] = "_blank";
|
||
}
|
||
const relIndex = token.attrIndex("rel");
|
||
if (relIndex < 0) {
|
||
token.attrPush(["rel", "noopener noreferrer"]);
|
||
} else {
|
||
token.attrs[relIndex][1] = "noopener noreferrer";
|
||
}
|
||
return defaultLinkOpen(tokens, idx, options, env, self);
|
||
};
|
||
markdownRendererRef.current = renderer;
|
||
}
|
||
|
||
const rendered = markdownRendererRef.current.render(raw);
|
||
return window.DOMPurify.sanitize(rendered, {
|
||
USE_PROFILES: { html: true },
|
||
ADD_TAGS: ["input", "label"],
|
||
ADD_ATTR: ["type", "checked", "disabled", "class"],
|
||
});
|
||
}
|
||
|
||
const codeBlocks = [];
|
||
let text = raw.replace(
|
||
/```(\w+)?\n([\s\S]*?)```/g,
|
||
(match, lang, code) => {
|
||
const safeLang = lang
|
||
? ` class="language-${escapeHtml(lang)}"`
|
||
: "";
|
||
const index = codeBlocks.length;
|
||
codeBlocks.push(
|
||
`<pre><code${safeLang}>${escapeHtml(code)}</code></pre>`,
|
||
);
|
||
return `@@CODEBLOCK${index}@@`;
|
||
},
|
||
);
|
||
|
||
const lines = text.split(/\r?\n/);
|
||
let html = "";
|
||
let paragraph = [];
|
||
let list = null;
|
||
let detailBreak = false;
|
||
let lastLineWasListItem = false;
|
||
|
||
const flushParagraph = () => {
|
||
if (paragraph.length) {
|
||
html += `<p>${inlineMarkdownToHtml(paragraph.join(" "))}</p>`;
|
||
paragraph = [];
|
||
}
|
||
};
|
||
const flushList = () => {
|
||
if (list) {
|
||
const items = list.items
|
||
.map((item) => {
|
||
const details =
|
||
item.details && item.details.length > 0
|
||
? item.details
|
||
.map(
|
||
(detail) => `<p>${inlineMarkdownToHtml(detail)}</p>`,
|
||
)
|
||
.join("")
|
||
: "";
|
||
const children =
|
||
item.children && item.children.length > 0
|
||
? `<ul>${item.children.map((child) => `<li>${inlineMarkdownToHtml(child)}</li>`).join("")}</ul>`
|
||
: "";
|
||
return `<li>${inlineMarkdownToHtml(item.content)}${details}${children}</li>`;
|
||
})
|
||
.join("");
|
||
html += `<${list.type}>${items}</${list.type}>`;
|
||
list = null;
|
||
}
|
||
};
|
||
const addListItem = (targetList, value) => {
|
||
targetList.items.push({ content: value, children: [], details: [] });
|
||
lastLineWasListItem = true;
|
||
};
|
||
const addDetailToLastItem = (targetList, value, newParagraph) => {
|
||
const lastItem = targetList.items[targetList.items.length - 1];
|
||
if (!lastItem) {
|
||
return;
|
||
}
|
||
if (newParagraph || lastItem.details.length === 0) {
|
||
lastItem.details.push(value);
|
||
} else {
|
||
lastItem.details[lastItem.details.length - 1] += ` ${value}`;
|
||
}
|
||
lastLineWasListItem = false;
|
||
};
|
||
|
||
const getListType = (value) => {
|
||
if (/^\d+\.\s+/.test(value)) {
|
||
return "ol";
|
||
}
|
||
if (/^[-*+]\s+/.test(value)) {
|
||
return "ul";
|
||
}
|
||
return null;
|
||
};
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const trimmed = lines[i].trim();
|
||
if (trimmed === "") {
|
||
let nextIndex = i + 1;
|
||
while (nextIndex < lines.length && lines[nextIndex].trim() === "") {
|
||
nextIndex += 1;
|
||
}
|
||
const nextLine =
|
||
nextIndex < lines.length ? lines[nextIndex].trim() : "";
|
||
const nextType = getListType(nextLine);
|
||
if (list && nextType && nextType === list.type) {
|
||
continue;
|
||
}
|
||
if (
|
||
list &&
|
||
list.type === "ol" &&
|
||
nextLine &&
|
||
!nextType &&
|
||
!nextLine.startsWith("@@CODEBLOCK") &&
|
||
!/^(#{1,6})\s+/.test(nextLine)
|
||
) {
|
||
detailBreak = true;
|
||
lastLineWasListItem = false;
|
||
continue;
|
||
}
|
||
flushList();
|
||
flushParagraph();
|
||
lastLineWasListItem = false;
|
||
continue;
|
||
}
|
||
|
||
if (trimmed.startsWith("@@CODEBLOCK")) {
|
||
flushList();
|
||
flushParagraph();
|
||
html += trimmed;
|
||
lastLineWasListItem = false;
|
||
continue;
|
||
}
|
||
|
||
const headingMatch = trimmed.match(/^(#{1,6})\s+(.*)$/);
|
||
if (headingMatch) {
|
||
flushList();
|
||
flushParagraph();
|
||
const level = headingMatch[1].length;
|
||
html += `<h${level}>${inlineMarkdownToHtml(headingMatch[2])}</h${level}>`;
|
||
lastLineWasListItem = false;
|
||
continue;
|
||
}
|
||
|
||
const unorderedMatch = trimmed.match(/^[-*+]\s+(.*)$/);
|
||
const orderedMatch = trimmed.match(/^\d+\.\s+(.*)$/);
|
||
if (unorderedMatch || orderedMatch) {
|
||
flushParagraph();
|
||
detailBreak = false;
|
||
const type = orderedMatch ? "ol" : "ul";
|
||
let value =
|
||
(orderedMatch ? orderedMatch[1] : unorderedMatch[1]) || "";
|
||
if (orderedMatch) {
|
||
value = value.replace(/^\d+\.\s+/, "");
|
||
}
|
||
if (
|
||
!orderedMatch &&
|
||
list &&
|
||
list.type === "ol" &&
|
||
list.items.length > 0
|
||
) {
|
||
list.items[list.items.length - 1].children.push(value);
|
||
continue;
|
||
}
|
||
if (!list || list.type !== type) {
|
||
flushList();
|
||
list = { type, items: [] };
|
||
}
|
||
addListItem(list, value);
|
||
continue;
|
||
}
|
||
|
||
if (
|
||
list &&
|
||
list.type === "ol" &&
|
||
(lastLineWasListItem || detailBreak)
|
||
) {
|
||
addDetailToLastItem(list, trimmed, detailBreak);
|
||
detailBreak = false;
|
||
continue;
|
||
}
|
||
|
||
if (list) {
|
||
flushList();
|
||
}
|
||
paragraph.push(trimmed);
|
||
lastLineWasListItem = false;
|
||
}
|
||
|
||
flushList();
|
||
flushParagraph();
|
||
|
||
codeBlocks.forEach((block, index) => {
|
||
html = html.replace(`@@CODEBLOCK${index}@@`, block);
|
||
});
|
||
|
||
return html;
|
||
};
|
||
const renderMessageContent = (content, allowMarkdown) => {
|
||
if (!allowMarkdown) {
|
||
return normalizeMessageContent(content);
|
||
}
|
||
return wp.element.createElement(RawHTML, null, markdownToHtml(content));
|
||
};
|
||
|
||
const lastActiveTimelineIndex = findLastActiveTimelineIndex(messages);
|
||
const groups = [];
|
||
let currentAiGroup = null;
|
||
|
||
messages.forEach((message, index) => {
|
||
if (message.role === "user") {
|
||
groups.push({ type: "user", message, key: `user-${index}` });
|
||
currentAiGroup = null;
|
||
return;
|
||
}
|
||
|
||
if (!currentAiGroup) {
|
||
currentAiGroup = { type: "ai", items: [], key: `ai-${index}` };
|
||
groups.push(currentAiGroup);
|
||
}
|
||
|
||
currentAiGroup.items.push({ message, index });
|
||
});
|
||
|
||
return groups.map((group, groupIndex) => {
|
||
if (group.type === "user") {
|
||
return wp.element.createElement(
|
||
"div",
|
||
{
|
||
key: group.key,
|
||
className: "wpaw-message wpaw-message-user",
|
||
},
|
||
wp.element.createElement(
|
||
"div",
|
||
{ className: "wpaw-message-content" },
|
||
renderMessageContent(group.message.content, false),
|
||
),
|
||
);
|
||
}
|
||
|
||
const isLastGroup = groupIndex === groups.length - 1;
|
||
let streamingLabel = "Streaming...";
|
||
for (let i = group.items.length - 1; i >= 0; i--) {
|
||
const item = group.items[i].message;
|
||
if (item.type === "timeline" && item.status) {
|
||
if (item.status === "checking") {
|
||
streamingLabel = "Analyzing...";
|
||
} else if (
|
||
item.status === "planning" ||
|
||
item.status === "plan_complete"
|
||
) {
|
||
streamingLabel = "Planning...";
|
||
} else if (
|
||
item.status === "writing" ||
|
||
item.status === "writing_section"
|
||
) {
|
||
streamingLabel = "Writing...";
|
||
} else if (item.status === "refining") {
|
||
streamingLabel = "Refining...";
|
||
} else {
|
||
streamingLabel = "Streaming...";
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
return wp.element.createElement(
|
||
"div",
|
||
{
|
||
key: group.key,
|
||
className: "wpaw-ai-response",
|
||
},
|
||
group.items.map((item, itemIndex) => {
|
||
const message = item.message;
|
||
const index = item.index;
|
||
const isLastItem = itemIndex === group.items.length - 1;
|
||
|
||
if (message.type === "timeline") {
|
||
const statusClass =
|
||
message.status === "complete"
|
||
? "complete"
|
||
: message.status === "inactive"
|
||
? "inactive"
|
||
: "active";
|
||
const showProcessing = isLoading && message.status === "refining";
|
||
const elapsedTime =
|
||
message.status === "complete" &&
|
||
message.timestamp &&
|
||
message.completedAt
|
||
? (
|
||
(new Date(message.completedAt) -
|
||
new Date(message.timestamp)) /
|
||
1000
|
||
).toFixed(1) + "s"
|
||
: null;
|
||
return wp.element.createElement(
|
||
"div",
|
||
{
|
||
key: `timeline-${index}`,
|
||
className:
|
||
"wpaw-ai-item wpaw-timeline-entry " +
|
||
statusClass +
|
||
(index === lastActiveTimelineIndex ? " is-current" : ""),
|
||
},
|
||
wp.element.createElement("div", {
|
||
className: "wpaw-timeline-dot",
|
||
"aria-hidden": "true",
|
||
}),
|
||
wp.element.createElement(
|
||
"div",
|
||
{ className: "wpaw-timeline-content" },
|
||
wp.element.createElement(
|
||
"div",
|
||
{ className: "wpaw-timeline-message" },
|
||
normalizeMessageContent(message.message),
|
||
),
|
||
message.status === "complete" &&
|
||
wp.element.createElement(
|
||
"div",
|
||
{ className: "wpaw-timeline-complete" },
|
||
"✓ Complete",
|
||
elapsedTime &&
|
||
wp.element.createElement(
|
||
"span",
|
||
{ className: "wpaw-timeline-elapsed" },
|
||
` (${elapsedTime})`,
|
||
),
|
||
),
|
||
showProcessing &&
|
||
wp.element.createElement(
|
||
"div",
|
||
{ className: "wpaw-processing-indicator" },
|
||
wp.element.createElement("span", {
|
||
className: "wpaw-dots-loader",
|
||
}),
|
||
wp.element.createElement(
|
||
"span",
|
||
null,
|
||
"Processing updates…",
|
||
),
|
||
),
|
||
!showProcessing &&
|
||
isLoading &&
|
||
isLastGroup &&
|
||
isLastItem &&
|
||
wp.element.createElement(
|
||
"div",
|
||
{
|
||
className: "wpaw-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);
|