refactor: Cleanup git state - commit all staged changes
Major refactoring cleanup: - Add new controller architecture (class-controller-*.php) - Add new settings-v2 UI (views/settings-v2/) - Add new CSS architecture (agentic-sidebar.css, tokens) - Add esbuild build pipeline (scripts/build.js, package.json) - Add composer dependencies (vendor/) - Add frontend src directory (assets/js/src/index.jsx) - Add documentation files - Remove old/obsolete files (class-settings.php, old CSS) This commits all pending changes from previous refactoring efforts.
This commit is contained in:
616
includes/class-controller-writing.php
Normal file
616
includes/class-controller-writing.php
Normal file
@@ -0,0 +1,616 @@
|
||||
<?php
|
||||
/**
|
||||
* Writing REST Controller
|
||||
*
|
||||
* Handles article writing and formatting operations.
|
||||
*
|
||||
* @package WP_Agentic_Writer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Class WP_Agentic_Writer_Controller_Writing
|
||||
*
|
||||
* REST controller for writing operations including article execution and block formatting.
|
||||
*
|
||||
* @since 0.3.0
|
||||
*/
|
||||
class WP_Agentic_Writer_Controller_Writing
|
||||
{
|
||||
/**
|
||||
* Sidebar instance for dependency access.
|
||||
*
|
||||
* @var WP_Agentic_Writer_Gutenberg_Sidebar
|
||||
*/
|
||||
private $sidebar;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @since 0.3.0
|
||||
* @param WP_Agentic_Writer_Gutenberg_Sidebar $sidebar Sidebar instance.
|
||||
*/
|
||||
public function __construct($sidebar)
|
||||
{
|
||||
$this->sidebar = $sidebar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle execute article request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_execute_article($request)
|
||||
{
|
||||
// Check rate limit.
|
||||
$rate_limit = WPAW_Rate_Limiter::check("execute_article");
|
||||
if (is_wp_error($rate_limit)) {
|
||||
return $rate_limit;
|
||||
}
|
||||
|
||||
$params = $request->get_json_params();
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$session_id = $this->sidebar->resolve_or_create_session_id(
|
||||
$params["sessionId"] ?? "",
|
||||
$post_id,
|
||||
);
|
||||
$stream = $params["stream"] ?? false;
|
||||
$recommended_title = "";
|
||||
$chat_history = $params["chatHistory"] ?? [];
|
||||
$post_config = $this->sidebar->resolve_post_config_from_request(
|
||||
$params,
|
||||
$post_id,
|
||||
);
|
||||
$post_config_context = $this->sidebar->build_post_config_context($post_config);
|
||||
$stored_language = get_post_meta(
|
||||
$post_id,
|
||||
"_wpaw_detected_language",
|
||||
true,
|
||||
);
|
||||
$detected_language = $params["detectedLanguage"] ?? $stored_language;
|
||||
$effective_language = $this->sidebar->resolve_language_preference(
|
||||
$post_config,
|
||||
$detected_language,
|
||||
);
|
||||
|
||||
// Auto-save post and link conversation if needed (only for post_id = 0)
|
||||
if (empty($post_id) && !empty($session_id)) {
|
||||
$post_id = $this->sidebar->ensure_conversation_linked_to_post(
|
||||
$session_id,
|
||||
$post_id,
|
||||
);
|
||||
}
|
||||
|
||||
// Check post permission if post_id is provided.
|
||||
if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) {
|
||||
return new WP_Error(
|
||||
"forbidden",
|
||||
__(
|
||||
"You do not have permission to edit this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
// Get plan from post meta.
|
||||
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
||||
|
||||
if (empty($plan)) {
|
||||
return new WP_Error(
|
||||
"no_plan",
|
||||
__(
|
||||
"No plan found. Please generate a plan first.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
if ($stream) {
|
||||
// For streaming, link conversation to post BEFORE getting plan from meta
|
||||
if (empty($post_id) && !empty($session_id)) {
|
||||
$post_id = $this->sidebar->ensure_conversation_linked_to_post(
|
||||
$session_id,
|
||||
$post_id,
|
||||
);
|
||||
}
|
||||
|
||||
// Now get plan after potentially having a valid post_id
|
||||
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
||||
if (empty($plan)) {
|
||||
echo "data: " .
|
||||
wp_json_encode([
|
||||
"type" => "error",
|
||||
"message" =>
|
||||
"No plan found. Please generate a plan first.",
|
||||
]) .
|
||||
"\n\n";
|
||||
flush();
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sidebar->stream_execute_article(
|
||||
$plan,
|
||||
$post_id,
|
||||
$post_config,
|
||||
$effective_language,
|
||||
$session_id,
|
||||
);
|
||||
exit();
|
||||
}
|
||||
|
||||
$plan = $this->sidebar->ensure_plan_sections_with_tasks($plan);
|
||||
|
||||
// MEMANTO: Plan execution implies approval.
|
||||
do_action("wpaw_memanto_plan_approved", $post_id, $plan);
|
||||
|
||||
// Update post title from the plan title when available.
|
||||
if (!empty($plan["title"])) {
|
||||
$recommended_title = sanitize_text_field($plan["title"]);
|
||||
if ($post_id > 0) {
|
||||
$post = get_post($post_id);
|
||||
if ($post && current_user_can("edit_post", $post_id)) {
|
||||
if (empty($post->post_title)) {
|
||||
wp_update_post([
|
||||
"ID" => $post_id,
|
||||
"post_title" => $recommended_title,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get provider for writing task.
|
||||
$provider_result = WP_Agentic_Writer_Provider_Manager::get_provider_for_task(
|
||||
"writing",
|
||||
);
|
||||
$provider = $provider_result->provider;
|
||||
|
||||
$image_instruction = "IMAGE SUGGESTIONS:
|
||||
- Suggest where images would enhance understanding
|
||||
- Place image suggestions on their own line using this format: [IMAGE: descriptive alt text]
|
||||
- Be strategic: only suggest images where they add real value (diagrams, screenshots, visual examples)
|
||||
- Good places for images: after introductions, before complex explanations, to show examples
|
||||
- Maximum 1-2 image suggestions per section
|
||||
- Example: [IMAGE: Screenshot of the plugin settings panel showing the API key field]";
|
||||
if (empty($post_config["include_images"])) {
|
||||
$image_instruction = "IMAGE SUGGESTIONS:
|
||||
- Do NOT include any image suggestions or [IMAGE: ...] placeholders.";
|
||||
}
|
||||
|
||||
$language_instruction = $this->sidebar->build_language_instruction(
|
||||
$effective_language,
|
||||
"article content",
|
||||
);
|
||||
|
||||
// Build chat history context for continuity
|
||||
$chat_history_context = "";
|
||||
if (!empty($chat_history) && is_array($chat_history)) {
|
||||
$chat_history_context = "\n\n--- CONVERSATION CONTEXT ---\n";
|
||||
foreach ($chat_history as $msg) {
|
||||
$role = isset($msg["role"]) ? ucfirst($msg["role"]) : "Unknown";
|
||||
$content = isset($msg["content"]) ? $msg["content"] : "";
|
||||
if (
|
||||
!empty($content) &&
|
||||
"system" !== strtolower($msg["role"] ?? "")
|
||||
) {
|
||||
$chat_history_context .= "{$role}: {$content}\n\n";
|
||||
}
|
||||
}
|
||||
$chat_history_context .= "--- END CONVERSATION CONTEXT ---\n";
|
||||
$chat_history_context .=
|
||||
"Use the above conversation to understand the user's intent and preferences for this article.";
|
||||
}
|
||||
|
||||
// Build SEO instructions if SEO is enabled
|
||||
$seo_instruction = "";
|
||||
$internal_links_instruction = "";
|
||||
if (
|
||||
!empty($post_config["seo_enabled"]) &&
|
||||
!empty($post_config["seo_focus_keyword"])
|
||||
) {
|
||||
$focus_keyword = $post_config["seo_focus_keyword"];
|
||||
$seo_instruction = "\n\nSEO OPTIMIZATION REQUIREMENTS (CRITICAL - MUST FOLLOW):
|
||||
- Focus Keyword: \"{$focus_keyword}\"
|
||||
- MANDATORY: Include the exact focus keyword \"{$focus_keyword}\" in the article title (preferably at the beginning)
|
||||
- MANDATORY: Use the focus keyword in the FIRST paragraph (within the first 100 words)
|
||||
- Use the focus keyword 5-8 times naturally throughout the article
|
||||
- Include the focus keyword in:
|
||||
* At least 2 H2 or H3 subheadings
|
||||
* The conclusion paragraph
|
||||
- Include 2-3 authoritative outbound links to reputable sources (Wikipedia, official documentation, industry leaders)
|
||||
- When suggesting images, include the focus keyword or related terms in the alt text
|
||||
- Keep the article title under 60 characters";
|
||||
|
||||
// Get internal link suggestions
|
||||
$internal_links = $this->sidebar->suggest_internal_links(
|
||||
$post_id,
|
||||
$focus_keyword,
|
||||
3,
|
||||
);
|
||||
if (!empty($internal_links)) {
|
||||
$internal_links_instruction =
|
||||
"\n\nINTERNAL LINKS (optional - use where contextually relevant):\n";
|
||||
foreach ($internal_links as $link) {
|
||||
$internal_links_instruction .= "- [{$link["title"]}]({$link["url"]})\n";
|
||||
}
|
||||
$internal_links_instruction .=
|
||||
"Naturally incorporate 1-2 of these internal links where they add value to the reader. Use descriptive anchor text, not 'click here'.";
|
||||
}
|
||||
}
|
||||
|
||||
// Build system prompt for article generation.
|
||||
$system_prompt = "You are an industry practitioner sharing insights with a colleague. Write engaging, high-information-density content based on the provided article plan.
|
||||
|
||||
ANTI-ROBOT RULES:
|
||||
- BANNED WORDS: delve, furthermore, moreover, crucial, paramount, landscape, testament, in today's digital world, in conclusion.
|
||||
- BANNED PATTERNS: Do not use transition words to start paragraphs. Do not summarize what you are about to say. Do not summarize what you just said.
|
||||
- BURSTINESS: Mix very short, punchy sentences (3-5 words) with longer, descriptive ones. Avoid uniform sentence length.
|
||||
- TONE: Conversational, direct, pragmatic. Do not sound like an academic 'expert' or textbook.
|
||||
|
||||
GEO/SEO STRATEGY:
|
||||
- Answer the implicit user intent directly and immediately in the first paragraph.
|
||||
- Maximize information density: high ratio of facts/insights to total word count. Remove filler adjectives.
|
||||
- Use bullet points or numbered lists where they make data easier to scan.
|
||||
|
||||
CRITICAL LANGUAGE REQUIREMENT:
|
||||
{$language_instruction}
|
||||
{$post_config_context}
|
||||
{$chat_history_context}
|
||||
|
||||
Follow these guidelines:
|
||||
- Use the tone specified in POST CONFIG if provided; otherwise be conversational but professional
|
||||
- Embed secondary keywords naturally as concepts, without forcing exact matches
|
||||
- For code blocks, use proper syntax highlighting (e.g., ```php)
|
||||
- Code typography: Use plain ASCII quotes inside code. Do NOT use smart quotes.
|
||||
- Write for the specified difficulty level
|
||||
{$seo_instruction}
|
||||
{$internal_links_instruction}
|
||||
|
||||
IMAGE SUGGESTIONS:
|
||||
- Suggest where images would enhance understanding (diagrams, screenshots)
|
||||
- Place image suggestions on their own line: [IMAGE: descriptive alt text]
|
||||
- Maximum 1 image per section
|
||||
{$image_instruction}";
|
||||
|
||||
// Generate content for each section.
|
||||
$blocks = [];
|
||||
$total_cost = 0;
|
||||
|
||||
$sections_to_write = [];
|
||||
foreach ($plan["sections"] as $index => $section) {
|
||||
$status = $section["status"] ?? "pending";
|
||||
if ("done" === $status) {
|
||||
continue;
|
||||
}
|
||||
$sections_to_write[$index] = $section;
|
||||
}
|
||||
|
||||
foreach ($sections_to_write as $section) {
|
||||
$heading = $section["heading"] ?? ($section["title"] ?? "");
|
||||
$section_prompt = $heading
|
||||
? "Write the \"{$heading}\" section.\n\n"
|
||||
: "Write the next section.\n\n";
|
||||
$section_prompt .= "Content requirements:\n";
|
||||
|
||||
if (!empty($section["content"]) && is_array($section["content"])) {
|
||||
foreach ($section["content"] as $item) {
|
||||
if (!empty($item["content"])) {
|
||||
$section_prompt .= "- {$item["content"]}\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$messages = [
|
||||
[
|
||||
"role" => "system",
|
||||
"content" => $system_prompt,
|
||||
],
|
||||
[
|
||||
"role" => "user",
|
||||
"content" => $section_prompt,
|
||||
],
|
||||
];
|
||||
|
||||
$response = $provider->chat(
|
||||
$messages,
|
||||
["temperature" => 0.8],
|
||||
"execution",
|
||||
);
|
||||
|
||||
if (is_wp_error($response)) {
|
||||
return new WP_Error(
|
||||
"execution_error",
|
||||
$response->get_error_message(),
|
||||
["status" => 500],
|
||||
);
|
||||
}
|
||||
|
||||
// Add section blocks.
|
||||
if ($heading) {
|
||||
$blocks[] = [
|
||||
"type" => "heading",
|
||||
"content" => $heading,
|
||||
"level" => 2,
|
||||
];
|
||||
}
|
||||
|
||||
$section_blocks = WP_Agentic_Writer_Markdown_Parser::parse(
|
||||
$response["content"],
|
||||
);
|
||||
if (!empty($section_blocks)) {
|
||||
$first_block = $section_blocks[0];
|
||||
if (
|
||||
isset($first_block["blockName"]) &&
|
||||
"core/heading" === $first_block["blockName"]
|
||||
) {
|
||||
$first_heading = $first_block["attrs"]["content"] ?? "";
|
||||
if (
|
||||
$heading &&
|
||||
$first_heading &&
|
||||
0 === strcasecmp(trim($first_heading), trim($heading))
|
||||
) {
|
||||
array_shift($section_blocks);
|
||||
}
|
||||
}
|
||||
foreach ($section_blocks as $block) {
|
||||
$blocks[] = $block;
|
||||
}
|
||||
} else {
|
||||
$blocks[] = [
|
||||
"type" => "paragraph",
|
||||
"content" => $response["content"],
|
||||
];
|
||||
}
|
||||
|
||||
$total_cost += $response["cost"];
|
||||
|
||||
// MEMANTO: Remember section written.
|
||||
$section_id = $section["id"] ?? sanitize_title($heading);
|
||||
do_action(
|
||||
"wpaw_memanto_section_written",
|
||||
$post_id,
|
||||
$section_id,
|
||||
$heading,
|
||||
);
|
||||
}
|
||||
|
||||
if (!empty($sections_to_write)) {
|
||||
foreach (array_keys($sections_to_write) as $section_index) {
|
||||
$plan["sections"][$section_index]["status"] = "done";
|
||||
}
|
||||
if ($post_id > 0) {
|
||||
update_post_meta($post_id, "_wpaw_plan", $plan);
|
||||
}
|
||||
}
|
||||
|
||||
// Track total cost.
|
||||
$this->sidebar->track_ai_cost(
|
||||
$post_id,
|
||||
$this->sidebar->get_provider_execution_model($provider, "execution"),
|
||||
"execution",
|
||||
0,
|
||||
0,
|
||||
$total_cost,
|
||||
$provider_result,
|
||||
"",
|
||||
"success",
|
||||
);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"blocks" => $blocks,
|
||||
"cost" => $total_cost,
|
||||
"recommended_title" => $recommended_title,
|
||||
"provider_metadata" => $this->sidebar->build_provider_metadata(
|
||||
$provider_result,
|
||||
$this->sidebar->get_provider_execution_model($provider, "execution"),
|
||||
),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle reformat blocks request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_reformat_blocks($request)
|
||||
{
|
||||
$params = $request->get_json_params();
|
||||
$blocks = $params["blocks"] ?? [];
|
||||
$post_id = $params["postId"] ?? 0;
|
||||
$recommended_title = "";
|
||||
$title_updated = false;
|
||||
|
||||
if (empty($blocks) || !is_array($blocks)) {
|
||||
return new WP_Error(
|
||||
"no_blocks",
|
||||
__("Blocks are required to reformat.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
// Check post permission if post_id is provided.
|
||||
if ($post_id > 0 && !$this->sidebar->check_post_permission($post_id)) {
|
||||
return new WP_Error(
|
||||
"forbidden",
|
||||
__(
|
||||
"You do not have permission to edit this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
$results = [];
|
||||
|
||||
if ($post_id > 0) {
|
||||
$plan = get_post_meta($post_id, "_wpaw_plan", true);
|
||||
if (is_array($plan) && !empty($plan["title"])) {
|
||||
$recommended_title = sanitize_text_field($plan["title"]);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($blocks as $block) {
|
||||
$client_id =
|
||||
$block["clientId"] ?? ($block["attrs"]["clientId"] ?? "");
|
||||
$block_type =
|
||||
$block["name"] ?? ($block["blockName"] ?? "core/paragraph");
|
||||
$block_attrs = $block["attributes"] ?? ($block["attrs"] ?? []);
|
||||
|
||||
if (empty($client_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ("core/paragraph" !== $block_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$content = $this->sidebar->extract_block_content_from_attrs(
|
||||
$block_type,
|
||||
$block_attrs,
|
||||
);
|
||||
if ("" === trim((string) $content)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed_blocks = WP_Agentic_Writer_Markdown_Parser::parse($content);
|
||||
if (empty($parsed_blocks)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
"clientId" => $client_id,
|
||||
"blocks" => $parsed_blocks,
|
||||
];
|
||||
}
|
||||
|
||||
if ($post_id > 0 && "" !== $recommended_title) {
|
||||
$post = get_post($post_id);
|
||||
if ($post && current_user_can("edit_post", $post_id)) {
|
||||
if (empty($post->post_title)) {
|
||||
wp_update_post([
|
||||
"ID" => $post_id,
|
||||
"post_title" => $recommended_title,
|
||||
]);
|
||||
$title_updated = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"results" => $results,
|
||||
"recommended_title" => $recommended_title,
|
||||
"title_updated" => $title_updated,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle save section blocks request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_save_section_blocks($request)
|
||||
{
|
||||
$params = $request->get_json_params();
|
||||
$post_id = intval($params["postId"] ?? 0);
|
||||
$section_id = sanitize_text_field($params["sectionId"] ?? "");
|
||||
$block_ids = $params["blockIds"] ?? [];
|
||||
|
||||
if ($post_id <= 0 || empty($section_id) || !is_array($block_ids)) {
|
||||
return new WP_Error(
|
||||
"invalid_section_blocks",
|
||||
__(
|
||||
"Invalid section block mapping request.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->sidebar->check_post_permission($post_id)) {
|
||||
return new WP_Error(
|
||||
"forbidden",
|
||||
__(
|
||||
"You do not have permission to edit this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
$block_ids = array_values(
|
||||
array_filter(array_map("sanitize_text_field", $block_ids)),
|
||||
);
|
||||
|
||||
$mapping = get_post_meta($post_id, "_wpaw_section_blocks", true);
|
||||
if (!is_array($mapping)) {
|
||||
$mapping = [];
|
||||
}
|
||||
|
||||
$mapping[$section_id] = $block_ids;
|
||||
update_post_meta($post_id, "_wpaw_section_blocks", $mapping);
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"success" => true,
|
||||
"sectionId" => $section_id,
|
||||
"blockCount" => count($block_ids),
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle get section blocks request.
|
||||
*
|
||||
* @since 0.1.0
|
||||
* @param WP_REST_Request $request REST request.
|
||||
* @return WP_REST_Response|WP_Error Response.
|
||||
*/
|
||||
public function handle_get_section_blocks($request)
|
||||
{
|
||||
$post_id = intval($request["post_id"] ?? 0);
|
||||
if ($post_id <= 0) {
|
||||
return new WP_Error(
|
||||
"invalid_post",
|
||||
__("Invalid post ID.", "wp-agentic-writer"),
|
||||
["status" => 400],
|
||||
);
|
||||
}
|
||||
|
||||
if (!$this->sidebar->check_post_permission($post_id)) {
|
||||
return new WP_Error(
|
||||
"forbidden",
|
||||
__(
|
||||
"You do not have permission to access this post.",
|
||||
"wp-agentic-writer",
|
||||
),
|
||||
["status" => 403],
|
||||
);
|
||||
}
|
||||
|
||||
$mapping = get_post_meta($post_id, "_wpaw_section_blocks", true);
|
||||
if (!is_array($mapping)) {
|
||||
$mapping = [];
|
||||
}
|
||||
|
||||
return new WP_REST_Response(
|
||||
[
|
||||
"sectionBlocks" => $mapping,
|
||||
],
|
||||
200,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user