]*>.*?#is';
/** Inline elements that belong INSIDE a paragraph. A model sometimes closes
* a paragraph, drops one of these bare between blocks, and opens a new
* paragraph — one sentence in three pieces. {@see heal_loose_inlines()}. */
const INLINE_TAGS = array( 'a', 'strong', 'em', 'b', 'i', 'code', 'u', 's', 'sub', 'sup', 'mark', 'small', 'abbr', 'kbd', 'br' );
/** @var Settings */
private $settings;
/**
* @param Settings $settings Settings store.
*/
public function __construct( Settings $settings ) {
$this->settings = $settings;
}
/**
* Hook the REST routes.
*/
public function register() {
add_action( 'rest_api_init', array( $this, 'routes' ) );
}
/**
* Launcher state for the admin bootstrap: what the quill needs to know to be
* live, dimmed-with-guidance, and honest about which prerequisite is missing.
*
* @return array{writesOn:bool,providerReady:bool}
*/
public function state() {
return array(
'writesOn' => (bool) $this->settings->enabled( 'enable_agent_writes' ),
'providerReady' => Assist::ai_available(),
'imageReady' => self::image_ready(),
'canUpload' => current_user_can( 'upload_files' ),
);
}
/**
* Whether the connected AI provider can generate images — drives the per-slot
* "Generate" buttons (feature-detected, never a dead-end button; the library
* picker works regardless).
*
* @return bool
*/
public static function image_ready() {
if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
return false;
}
try {
return (bool) wp_ai_client_prompt( 'capability probe' )->is_supported_for_image_generation();
} catch ( \Throwable $e ) {
return false;
}
}
/**
* Define the routes.
*/
public function routes() {
register_rest_route(
self::NS,
'/assistant/compose',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'compose' ),
'permission_callback' => array( $this, 'can_compose' ),
)
);
register_rest_route(
self::NS,
'/assistant/outline',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'outline' ),
'permission_callback' => array( $this, 'can_compose' ),
)
);
register_rest_route(
self::NS,
'/assistant/compose-section',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'compose_section' ),
'permission_callback' => array( $this, 'can_compose' ),
)
);
register_rest_route(
self::NS,
'/assistant/compose-meta',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'compose_meta' ),
'permission_callback' => array( $this, 'can_compose' ),
)
);
register_rest_route(
self::NS,
'/assistant/refine',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'refine' ),
'permission_callback' => array( $this, 'can_compose' ),
)
);
register_rest_route(
self::NS,
'/assistant/revise-block',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'revise_block' ),
'permission_callback' => array( $this, 'can_compose' ),
)
);
register_rest_route(
self::NS,
'/assistant/generate-image',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'generate_image' ),
'permission_callback' => array( $this, 'can_generate_image' ),
)
);
register_rest_route(
self::NS,
'/assistant/create',
array(
'methods' => \WP_REST_Server::EDITABLE,
'callback' => array( $this, 'create' ),
'permission_callback' => array( $this, 'can_create' ),
)
);
register_rest_route(
self::NS,
'/assistant/posts',
array(
'methods' => \WP_REST_Server::READABLE,
'callback' => array( $this, 'posts' ),
'permission_callback' => array( $this, 'can_compose' ),
)
);
register_rest_route(
self::NS,
'/assistant/post/(?P , , , ,
,
,
,
— no
, no images, no markdown fences, no document wrapper. '
. 'The parts are assembled in outline order, so write prose that flows: assume the reader has just read '
. 'the previous part, don\'t re-introduce the article, and don\'t cover ground the outline assigns elsewhere. '
. 'Write concretely in the brief\'s language; no filler, no invented facts or statistics. '
. self::readability_rules() . ' '
. 'Voice: follow the site content guidelines above when they declare one (their author, their person, '
. 'their tone are real — use them); never invent credentials, employers or anecdotes the guidelines '
. 'don\'t provide; and when the brief itself specifies a voice, the brief wins. '
. 'With no guidance from either, write in a neutral site voice.';
}
/**
* PURE: the staged-part user prompt — the SPINE (brief + complete outline)
* plus this part's own assignment: which part, its neighbours, its note,
* and its share of any requested length.
*
* @param string $brief The owner's brief.
* @param array $outline The sanitised outline.
* @param string $part intro|section|conclusion.
* @param int $index Section index for part=section.
* @return string
*/
public static function staged_part_prompt( $brief, array $outline, $part, $index = -1 ) {
$sections = $outline['sections'];
$count = count( $sections );
$spine = "The owner's brief:\n" . $brief
. "\n\nThe approved outline for the whole article, as JSON:\n"
. wp_json_encode( $outline, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
if ( 'intro' === $part ) {
return $spine . "\n\nWrite the article's INTRODUCTION: one to three paragraphs that set up the piece "
. 'the outline describes and lead into the first section, "' . $sections[0]['heading'] . '". '
. 'Its FIRST paragraph must be a self-contained two-or-three-sentence summary of the whole '
. 'article — answer engines lift it as the summary. '
. 'No heading of its own — paragraphs only. Don\'t cover the sections\' ground; they follow.';
}
if ( 'conclusion' === $part ) {
return $spine . "\n\nWrite the article's CLOSING: a short ending that lands the piece after its last "
. 'section, "' . $sections[ $count - 1 ]['heading'] . '" — one or two paragraphs. '
. 'No heading of its own. Don\'t open new ground and don\'t mechanically summarise every section.';
}
$section = $sections[ $index ];
$neighbors = 0 === $index
? 'It is the first section — the introduction leads straight into it.'
: 'The section before it is "' . $sections[ $index - 1 ]['heading'] . '".';
if ( $index === $count - 1 ) {
$neighbors .= ' It is the last section — a short closing follows it.';
} else {
$neighbors .= ' The section after it is "' . $sections[ $index + 1 ]['heading'] . '".';
}
return $spine . "\n\nWrite section " . ( $index + 1 ) . ' of ' . $count . ': "' . $section['heading'] . '".'
. ( '' !== $section['note'] ? "\nWhat it covers: " . $section['note'] : '' )
. "\n" . $neighbors
. "\nBegin with exactly
" . $section['heading'] . '
— that heading text verbatim — then the '
. 'section\'s body; subheadings inside it are welcome. '
. 'Write it at natural, complete length for one section of this article; if the brief asks for an '
. 'overall length, aim for this section\'s share of it.';
}
/**
* The staged dressing instruction: everything about the post EXCEPT its body
* — same fields and bounds as the whole-document contract, minus "content",
* with image anchors tied to the outline's own headings.
*
* @return string
*/
public static function staged_meta_system_prompt() {
return 'You prepare the DRESSING for a WordPress post that is being written section by section from an '
. 'owner-approved outline — everything about the post except its body text. '
. 'Respond with ONLY a single JSON object — no markdown fences, no commentary before or after — with exactly these keys: '
. '"title" (string: the post title — use the outline\'s title unless the brief itself demands another), '
. '"excerpt" (string, 1–2 plain sentences), '
. '"description" (string: one sentence under 160 characters saying what the page is about, for AI assistants), '
. '"topics" (array of 3–6 short topic phrases), '
. '"tags" (array of 2–5 tag names), '
. '"categories" (array of 0–2 category names, only if clearly implied by the brief), '
. '"images" (array of 0–4 image SUGGESTIONS, only where a picture would genuinely help the reader: '
. 'each {"alt": a rich, self-contained visual description an image generator could paint from — subject, setting, mood, '
. '"after_heading": the exact text of one of the outline\'s section headings, verbatim, that the image should follow, '
. 'or "" for right after the introduction}). '
. 'Ground everything in the brief and the outline; write in the brief\'s language; no invented facts.';
}
/**
* PURE: the staged dressing user prompt — the same spine the parts carry.
*
* @param string $brief The owner's brief.
* @param array $outline The sanitised outline.
* @return string
*/
public static function staged_meta_prompt( $brief, array $outline ) {
return "The owner's brief:\n" . $brief
. "\n\nThe approved outline for the whole article, as JSON:\n"
. wp_json_encode( $outline, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
. "\n\nReturn the dressing JSON for this article.";
}
/**
* PURE: turn the model's text into the sanitised dressing, or a clear error
* — the same fence/prose-tolerant cut every JSON contract uses.
*
* @param string $text Raw model output.
* @return array|\WP_Error
*/
public static function parse_meta( $text ) {
$text = trim( (string) $text );
$start = strpos( $text, '{' );
$end = strrpos( $text, '}' );
$data = ( false !== $start && false !== $end && $end > $start )
? json_decode( substr( $text, $start, $end - $start + 1 ), true )
: null;
return self::sanitize_meta( $data );
}
/**
* PURE: hold a dressing-shaped array to the schema — sanitize_draft() minus
* the body, with the same field rules, so the assembled staged draft is
* indistinguishable from a whole-document one.
*
* @param mixed $data Dressing-shaped input.
* @return array|\WP_Error
*/
public static function sanitize_meta( $data ) {
$title = is_array( $data ) ? sanitize_text_field( (string) ( $data['title'] ?? '' ) ) : '';
if ( '' === $title ) {
return new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return usable details for the post — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
}
return array(
'title' => $title,
'excerpt' => sanitize_text_field( (string) ( $data['excerpt'] ?? '' ) ),
'description' => mb_substr( sanitize_text_field( (string) ( $data['description'] ?? '' ) ), 0, 200 ),
'topics' => self::clean_list( $data['topics'] ?? array(), self::MAX_TOPICS ),
'tags' => self::clean_list( $data['tags'] ?? array(), self::MAX_TAGS ),
'categories' => self::clean_list( $data['categories'] ?? array(), self::MAX_CATEGORIES ),
'images' => self::clean_image_slots( $data['images'] ?? array() ),
);
}
/**
* PURE: a staged section opens with its outline heading VERBATIM — the
* anchor image slots and the approved outline both point at. Whatever
* heading the model wrote (right, paraphrased, or none) is replaced with
* ours; a section that was ONLY a heading is refused as empty.
*
* @param string $html The part's clean HTML.
* @param string $heading The outline's heading for this section.
* @return string|\WP_Error
*/
public static function enforce_section_heading( $html, $heading ) {
$body = self::strip_leading_heading( $html );
if ( is_wp_error( $body ) ) {
return $body;
}
return '
' . esc_html( $heading ) . '
' . $body;
}
/**
* PURE: cut ONE leading h2/h3 — the intro and closing never carry a heading
* of their own (the whole-document shape), and a section's model-written
* heading gives way to the verbatim outline heading. Refuses a part that
* was nothing but its heading.
*
* @param string $html The part's clean HTML.
* @return string|\WP_Error
*/
public static function strip_leading_heading( $html ) {
$html = (string) preg_replace( '#\A\s* section per outline entry, in the given order, with the given heading text verbatim. "
. 'Cover what each section\'s note describes. Do not add, remove, merge or reorder sections. '
. 'An introduction before the first section and a short closing after the last are welcome, without headings of their own.';
}
/**
* PURE: turn the model's text into a sanitised outline, or a clear error —
* the same fence/prose-tolerant cut {@see parse_draft()} uses.
*
* @param string $text Raw model output.
* @return array|\WP_Error
*/
public static function parse_outline( $text ) {
$text = trim( (string) $text );
$start = strpos( $text, '{' );
$end = strrpos( $text, '}' );
$data = ( false !== $start && false !== $end && $end > $start )
? json_decode( substr( $text, $start, $end - $start + 1 ), true )
: null;
if ( ! is_array( $data ) ) {
return new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return a usable outline — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
}
return self::sanitize_outline( $data );
}
/**
* PURE: hold an outline-shaped array to the schema — the shared gate for both
* directions, exactly like {@see sanitize_draft()}: model output on its way
* to the editor, and an owner-edited outline on its way back into the compose
* contract. A section needs a real heading; a heading-less row is dropped,
* and an outline needs a title and at least one surviving section to count.
*
* @param mixed $data Outline-shaped input.
* @return array|\WP_Error
*/
public static function sanitize_outline( $data ) {
$bad = new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return a usable outline — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
if ( ! is_array( $data ) ) {
return $bad;
}
$title = mb_substr( trim( sanitize_text_field( (string) ( $data['title'] ?? '' ) ) ), 0, 160 );
$sections = array();
foreach ( array_slice( (array) ( $data['sections'] ?? array() ), 0, self::MAX_OUTLINE_SECTIONS ) as $section ) {
if ( ! is_array( $section ) ) {
continue;
}
$heading = trim( sanitize_text_field( (string) ( $section['heading'] ?? '' ) ) );
if ( '' === $heading ) {
continue;
}
$sections[] = array(
'heading' => mb_substr( $heading, 0, 120 ),
'note' => mb_substr( trim( sanitize_text_field( (string) ( $section['note'] ?? '' ) ) ), 0, 240 ),
);
}
if ( '' === $title || ! $sections ) {
return $bad;
}
return array(
'title' => $title,
'sections' => $sections,
);
}
/**
* POST /assistant/refine — revise the HELD draft with a targeted instruction
* ("add a section on caching", "drop the checklist", "shorten the intro"):
* one AI call, the complete revised document back, still writing nothing.
* The current draft is re-sanitised BEFORE it rides in the prompt, so the
* model only ever sees what the preview showed.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function refine( \WP_REST_Request $request ) {
$instruction = trim( sanitize_textarea_field( (string) $request->get_param( 'instruction' ) ) );
if ( strlen( $instruction ) < 4 ) {
return new \WP_Error(
'agentimus_instruction_short',
__( 'Say what to change first — e.g. “add a short section on caching”.', 'agentimus' ),
array( 'status' => 400 )
);
}
if ( strlen( $instruction ) > 500 ) {
$instruction = substr( $instruction, 0, 500 );
}
$draft = self::sanitize_draft( $request->get_param( 'draft' ) );
if ( is_wp_error( $draft ) ) {
return $draft;
}
if ( ! Assist::ai_available() ) {
return new \WP_Error(
'agentimus_ai_unavailable',
__( 'No AI text model is configured. Add or enable one under Settings → AI, then try again.', 'agentimus' ),
array( 'status' => 503 )
);
}
$text = ( new Assist( $this->settings ) )->generate(
self::system_prompt(),
self::revision_prompt( $draft, $instruction ),
self::COMPOSE_TOKENS
);
if ( is_wp_error( $text ) ) {
return self::friendly_ai_error( $text );
}
$revised = self::parse_draft( $text );
if ( is_wp_error( $revised ) ) {
return $revised;
}
return rest_ensure_response( array( 'draft' => $revised ) );
}
/**
* PURE: the revision user-prompt — the current draft as JSON plus the owner's
* instruction, with preserve-everything-else stated explicitly so a one-line
* request can't silently rewrite the whole post.
*
* @param array $draft The sanitised current draft.
* @param string $instruction The owner's change request.
* @return string
*/
public static function revision_prompt( array $draft, $instruction ) {
return "Here is the current draft, as JSON:\n"
. wp_json_encode( $draft, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
. "\n\nRevision request: " . $instruction
. "\n\nReturn the COMPLETE revised draft as the same single JSON object with all the same keys. "
. 'Apply only the requested change; preserve everything else — the title, structure, wording, '
. 'tags, topics and categories — unless the request itself asks for them to change.';
}
/**
* POST /assistant/revise-block — the editor's per-section act: the user
* selected a block, typed an instruction, and the full post rides along as
* context. The instruction may REWRITE the section or ADD new blocks around
* it ("add a conclusion after this") — the model returns the section's
* final form as one-or-more blocks, and the editor swaps them in where the
* selection was. Writes nothing; the editor's own undo covers regret.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function revise_block( \WP_REST_Request $request ) {
$instruction = trim( sanitize_textarea_field( (string) $request->get_param( 'instruction' ) ) );
if ( strlen( $instruction ) < 4 ) {
return new \WP_Error(
'agentimus_instruction_short',
__( 'Say what to change first — e.g. “make this shorter” or “add a conclusion after this”.', 'agentimus' ),
array( 'status' => 400 )
);
}
if ( strlen( $instruction ) > 500 ) {
$instruction = substr( $instruction, 0, 500 );
}
// The selected section arrives as serialized block markup; the model
// works on clean HTML (comments stripped), same as everywhere else.
$section = wp_kses_post( (string) preg_replace( '/\s*/', '', (string) $request->get_param( 'block' ) ) );
if ( '' === trim( wp_strip_all_tags( $section ) ) ) {
return new \WP_Error(
'agentimus_block_empty',
__( 'Select a section with some text first.', 'agentimus' ),
array( 'status' => 400 )
);
}
$context = trim( (string) wp_strip_all_tags( (string) preg_replace( '//s', ' ', (string) $request->get_param( 'context' ) ) ) );
$context = mb_substr( (string) preg_replace( '/\s+/', ' ', $context ), 0, self::SECTION_CONTEXT_CHARS );
$title = sanitize_text_field( (string) $request->get_param( 'title' ) );
if ( ! Assist::ai_available() ) {
return new \WP_Error(
'agentimus_ai_unavailable',
__( 'No AI text model is configured. Add or enable one under Settings → AI, then try again.', 'agentimus' ),
array( 'status' => 503 )
);
}
$text = ( new Assist( $this->settings ) )->generate(
self::section_system_prompt(),
self::revise_block_prompt( $title, $context, $section, $instruction ),
self::SECTION_TOKENS
);
if ( is_wp_error( $text ) ) {
return self::friendly_ai_error( $text );
}
$html = self::clean_section_html( $text );
if ( is_wp_error( $html ) ) {
return $html;
}
// Native block markup back — the editor parses and swaps it straight in.
return rest_ensure_response( array( 'content' => self::blockify( $html ) ) );
}
/**
* The section-revision instruction: rewrite/extend ONE selected section,
* return its final form only — unchanged parts verbatim, additions allowed
* where the instruction puts them.
*
* @return string
*/
public static function section_system_prompt() {
return 'You revise ONE section of a WordPress post. The user selected that section; their instruction may ask you to '
. 'rewrite it, split it, or ADD new content before or after it. Respond with ONLY the section\'s final form — '
. 'the selected content plus any requested additions, in final order — as clean HTML using only '
. '
,
,
,
,
. '
. 'Keep everything the instruction does not ask to change EXACTLY as it is, word for word. '
. 'No commentary, no markdown fences, no
, no document wrapper. Write in the post\'s language and voice.';
}
/**
* PURE: the section-revision user prompt — title and capped plain-text
* context for grounding, the selected section, and the instruction.
*
* @param string $title Post title.
* @param string $context The full post as plain text (capped).
* @param string $section The selected section's clean HTML.
* @param string $instruction The owner's change request.
* @return string
*/
public static function revise_block_prompt( $title, $context, $section, $instruction ) {
$parts = array();
if ( '' !== $title ) {
$parts[] = 'Post title: ' . $title;
}
if ( '' !== $context ) {
$parts[] = "The full post, for context only — do NOT return it:\n" . $context;
}
$parts[] = "The selected section (HTML):\n" . $section;
$parts[] = 'Instruction: ' . $instruction;
$parts[] = 'Return only the final version of this section, with any requested additions in place.';
return implode( "\n\n", $parts );
}
/**
* PURE: model text → clean section HTML, or a clear error. Cuts the classic
* markdown fences, then holds the rest to post-safe HTML.
*
* @param string $text Raw model output.
* @return string|\WP_Error
*/
public static function clean_section_html( $text ) {
$text = trim( (string) $text );
if ( preg_match( '/^```[a-z]*\s*(.*?)\s*```$/is', $text, $m ) ) {
$text = $m[1];
}
$html = wp_kses_post( $text );
if ( '' === trim( wp_strip_all_tags( $html ) ) ) {
return new \WP_Error(
'agentimus_ai_bad_output',
__( 'The AI didn’t return a usable section — please try again.', 'agentimus' ),
array( 'status' => 502 )
);
}
return $html;
}
/**
* POST /assistant/generate-image — one image for one slot, on one explicit
* click. Two-step prompt chain (the good idea from the owner's earlier
* aiwriter plugin): the slot's alt text is a SEED, first rewritten by the text
* model into a vivid scene prompt, then rendered by the image model. The
* result lands in the media library with the ORIGINAL alt (that's the honest
* accessibility text — the scene prompt is generator jargon), EXIF-free by
* WordPress's own import pipeline. Never called in bulk: one slot, one click,
* one image — image generation is priced differently from text and the click
* is the consent.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function generate_image( \WP_REST_Request $request ) {
$alt = trim( sanitize_text_field( (string) $request->get_param( 'alt' ) ) );
if ( mb_strlen( $alt ) < 5 ) {
return new \WP_Error(
'agentimus_alt_short',
__( 'Describe the image in a sentence first.', 'agentimus' ),
array( 'status' => 400 )
);
}
$title = trim( sanitize_text_field( (string) $request->get_param( 'title' ) ) );
if ( ! self::image_ready() ) {
return new \WP_Error(
'agentimus_image_unavailable',
__( 'Your connected AI provider can’t generate images. Pick one from the media library instead, or connect an image-capable provider under Settings → AI.', 'agentimus' ),
array( 'status' => 503 )
);
}
// Step 1 — scene description (a TEXT call: rides the same rate limit as
// every other generation, which also bounds the paired image call below).
$scene = ( new Assist( $this->settings ) )->generate(
self::scene_system_prompt(),
( '' !== $title ? 'Article: ' . $title . "\n" : '' ) . 'Image description: ' . $alt,
self::SCENE_TOKENS
);
if ( is_wp_error( $scene ) ) {
return self::friendly_ai_error( $scene );
}
// Step 2 — the one image call. A model PREFERENCE (not a pin) rides
// along: the AI Client's default pick is the newest image model, which
// free tiers often have zero quota for, while an older stable sibling
// works — preferred models are tried in order and unknown ids fall
// back to the client's own choice, so a non-Google provider is never
// broken by this. Filterable for sites that know better.
try {
$preferred = apply_filters( 'agentimus_assistant_image_models', array( 'gemini-2.5-flash-image' ) );
$builder = wp_ai_client_prompt( $scene );
if ( is_array( $preferred ) && $preferred ) {
$builder = $builder->using_model_preference( ...array_values( $preferred ) );
}
$file = $builder->generate_image();
} catch ( \Throwable $e ) {
$file = new \WP_Error( 'agentimus_image_failed', $e->getMessage() );
}
if ( is_wp_error( $file ) ) {
return self::friendly_image_error( $file );
}
$attachment_id = self::import_image_file( $file, $alt );
if ( is_wp_error( $attachment_id ) ) {
return $attachment_id;
}
return rest_ensure_response(
array(
'id' => $attachment_id,
'url' => (string) ( wp_get_attachment_image_url( $attachment_id, 'medium' ) ?: wp_get_attachment_url( $attachment_id ) ),
'fullUrl' => (string) wp_get_attachment_url( $attachment_id ),
'alt' => $alt,
)
);
}
/**
* The scene-describer instruction: turn a slot's alt seed into one concrete
* image prompt. Blog-appropriate styles only; text-in-image banned (models
* mangle it, and the article already carries the words).
*
* @return string
*/
public static function scene_system_prompt() {
return 'You turn a short description of a blog-article image into ONE vivid, concrete image-generation prompt. '
. 'Describe subject, setting, composition, lighting and style in a single plain-text paragraph. '
. 'Prefer clean editorial illustration or natural photography suited to a professional blog. '
. 'No text, lettering, watermarks, logos or UI screenshots in the image. Return ONLY the prompt paragraph.';
}
/**
* Import a generated image (inline base64 or remote URL) through WordPress's
* own sideload pipeline — validation, size variants, clean metadata — and
* stamp the slot's alt on it. Temp files are cleaned on every path.
*
* @param object $file The AI client's File DTO.
* @param string $alt The slot's alt text.
* @return int|\WP_Error Attachment ID.
*/
private static function import_image_file( $file, $alt ) {
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$mime = method_exists( $file, 'getMimeType' ) ? (string) $file->getMimeType() : 'image/png';
$exts = array(
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/webp' => 'webp',
'image/gif' => 'gif',
);
$ext = isset( $exts[ $mime ] ) ? $exts[ $mime ] : 'png';
if ( method_exists( $file, 'isInline' ) && $file->isInline() ) {
$data = base64_decode( (string) $file->getBase64Data(), true );
if ( false === $data || '' === $data ) {
return new \WP_Error( 'agentimus_image_failed', __( 'The generated image came back unreadable — please try again.', 'agentimus' ), array( 'status' => 502 ) );
}
$tmp = wp_tempnam( 'agentimus-image.' . $ext );
if ( ! $tmp || false === file_put_contents( $tmp, $data ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions -- controlled temp file.
return new \WP_Error( 'agentimus_image_failed', __( 'Couldn’t stage the image for import.', 'agentimus' ), array( 'status' => 500 ) );
}
} else {
$tmp = download_url( (string) $file->getUrl() );
if ( is_wp_error( $tmp ) ) {
return $tmp;
}
}
$name = sanitize_file_name( 'assistant-' . sanitize_title( mb_substr( $alt, 0, 48 ) ) . '.' . $ext );
$attachment = media_handle_sideload(
array(
'name' => $name,
'tmp_name' => $tmp,
),
0,
mb_substr( $alt, 0, 120 ) // Attachment title = the human description.
);
if ( is_wp_error( $attachment ) ) {
@unlink( $tmp ); // phpcs:ignore WordPress.PHP.NoSilencedErrors, WordPress.WP.AlternativeFunctions -- best-effort temp cleanup.
return $attachment;
}
update_post_meta( (int) $attachment, '_wp_attachment_image_alt', $alt );
return (int) $attachment;
}
/**
* POST /assistant/create — materialise a previewed draft through the governed
* write path. The owner clicked the button; this is the ONLY place the
* assistant writes, and it can only ever produce a draft or a pending post.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function create( \WP_REST_Request $request ) {
// v1 never publishes — clamp BEFORE ContentWriter even sees the status, so
// the assistant can't go live even on a site whose publish rung is on.
$status = 'pending' === (string) $request->get_param( 'status' ) ? 'pending' : 'draft';
$input = array(
'type' => sanitize_key( (string) ( $request->get_param( 'type' ) ? $request->get_param( 'type' ) : 'post' ) ),
'title' => sanitize_text_field( (string) $request->get_param( 'title' ) ),
'content' => wp_kses_post( (string) $request->get_param( 'content' ) ),
'status' => $status,
);
$excerpt = sanitize_text_field( (string) $request->get_param( 'excerpt' ) );
if ( '' !== $excerpt ) {
$input['excerpt'] = $excerpt;
}
$description = sanitize_text_field( (string) $request->get_param( 'description' ) );
if ( '' !== $description ) {
$input['description'] = $description;
}
foreach ( array( 'topics', 'tags', 'categories' ) as $list ) {
$values = self::clean_list( $request->get_param( $list ), 'topics' === $list ? self::MAX_TOPICS : ( 'tags' === $list ? self::MAX_TAGS : self::MAX_CATEGORIES ) );
if ( $values ) {
$input[ $list ] = $values;
}
}
// Featured image: an attachment the owner chose or generated in the preview.
// ContentWriter re-validates it (is it an image, does the type support one).
$featured = absint( $request->get_param( 'featured_image' ) );
if ( $featured > 0 ) {
$input['featured_image'] = (string) $featured;
}
// Every proposed image lands in the article: a filled slot as its real
// figure, a bare suggestion as an alt-only PLACEHOLDER block — the
// editor is the image workshop (fill, generate, or delete it there).
$figures = self::figures_from_slots( $request->get_param( 'images' ) );
if ( $figures ) {
$input['content'] = self::inject_images( $input['content'], $figures );
}
// The editor should open REAL blocks, not a Classic block with a
// "Convert to blocks" chore — figures included, so this runs last.
$input['content'] = self::blockify( $input['content'] );
$result = ( new ContentWriter( $this->settings ) )->create( $input );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( array( 'post' => $result ) );
}
/**
* GET /assistant/posts — the edit-existing picker's search: the owner's own
* editable posts, each carrying an honest verdict on whether the assistant
* can rewrite it safely (and why not, when it can't).
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response
*/
public function posts( \WP_REST_Request $request ) {
$q = trim( sanitize_text_field( (string) $request->get_param( 'q' ) ) );
$query = new \WP_Query(
array(
'post_type' => Content::post_types(),
'post_status' => array( 'publish', 'draft', 'pending', 'private', 'future' ),
's' => $q,
'posts_per_page' => 10,
'orderby' => '' === $q ? 'modified' : 'relevance',
'order' => 'DESC',
'no_found_rows' => true,
)
);
$rows = array();
foreach ( $query->posts as $post ) {
if ( ! current_user_can( 'edit_post', $post->ID ) ) {
continue;
}
$reason = self::edit_gate_reason( $post->post_content );
$rows[] = array(
'id' => $post->ID,
'title' => '' !== $post->post_title ? $post->post_title : __( '(no title)', 'agentimus' ),
'status' => $post->post_status,
'statusLabel' => self::status_label( $post->post_status ),
'date' => get_the_modified_date( '', $post ),
'compatible' => '' === $reason,
'reason' => $reason,
);
}
return rest_ensure_response( array( 'posts' => $rows ) );
}
/**
* GET /assistant/post/{id} — one post as an editable DOCUMENT: the same
* draft shape compose produces, so the whole preview/refine/undo machinery
* works on it unchanged. Content comes back as clean image-free HTML; the
* post's figures become slots (attachment kept, heading anchor recovered).
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function fetch_post( \WP_REST_Request $request ) {
$post = get_post( absint( $request->get_param( 'id' ) ) );
$reason = self::edit_gate_reason( $post->post_content );
if ( '' !== $reason ) {
return new \WP_Error(
'agentimus_post_uneditable',
sprintf(
/* translators: %s: the reason this post can't be rewritten. */
__( 'This post %s — pick another, or edit it in the editor.', 'agentimus' ),
$reason
),
array( 'status' => 409 )
);
}
return rest_ensure_response( array( 'post' => self::post_to_doc( $post ) ) );
}
/**
* POST /assistant/update — materialise a revised document back onto its
* post through the governed write path. The ONE rule that has no exceptions:
* no status ever rides this call — the assistant edits content, never
* visibility. A draft stays a draft, a published post stays published, and
* WordPress keeps the previous version in Revisions.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function update( \WP_REST_Request $request ) {
$input = array(
'post_id' => (string) absint( $request->get_param( 'id' ) ),
'title' => sanitize_text_field( (string) $request->get_param( 'title' ) ),
'content' => wp_kses_post( (string) $request->get_param( 'content' ) ),
'excerpt' => sanitize_text_field( (string) $request->get_param( 'excerpt' ) ),
);
$description = sanitize_text_field( (string) $request->get_param( 'description' ) );
if ( '' !== $description ) {
$input['description'] = $description;
}
foreach ( array( 'topics', 'tags', 'categories' ) as $list ) {
$values = self::clean_list( $request->get_param( $list ), 'topics' === $list ? self::MAX_TOPICS : ( 'tags' === $list ? self::MAX_TAGS : self::MAX_CATEGORIES ) );
if ( $values ) {
$input[ $list ] = $values;
}
}
$featured = absint( $request->get_param( 'featured_image' ) );
if ( $featured > 0 ) {
$input['featured_image'] = (string) $featured;
}
// Same mapping as create(): kept images return as real figures,
// unfilled suggestions survive as placeholders — never silently lost.
$figures = self::figures_from_slots( $request->get_param( 'images' ) );
if ( $figures ) {
$input['content'] = self::inject_images( $input['content'], $figures );
}
$input['content'] = self::blockify( $input['content'] );
$result = ( new ContentWriter( $this->settings ) )->update( $input );
if ( is_wp_error( $result ) ) {
return $result;
}
return rest_ensure_response( array( 'post' => $result ) );
}
/**
* PURE: why a post can't be rewritten by the assistant, or '' when it can.
* Three honest gates: block vocabulary (anything outside what the assistant
* itself writes would be destroyed by the round-trip), image count (more
* figures than slots exist to carry them), and length (a whole-document
* rewrite must fit the output budget). Phrases complete the sentence
* "This post …".
*
* @param string $content Raw post_content (block markup or classic HTML).
* @return string Empty when editable; otherwise the reason.
*/
public static function edit_gate_reason( $content ) {
$content = (string) $content;
if ( preg_match_all( '/\s*/', '', $content );
$slots = array();
if ( preg_match_all( self::FIGURE_RX, $content, $m, PREG_OFFSET_CAPTURE ) ) {
foreach ( $m[0] as $hit ) {
list( $figure, $offset ) = $hit;
$slot = array(
'alt' => '',
'after_heading' => '',
);
if ( preg_match( '/\balt="([^"]*)"/i', $figure, $a ) ) {
$slot['alt'] = html_entity_decode( $a[1], ENT_QUOTES );
}
if ( mb_strlen( trim( $slot['alt'] ) ) < 5 ) {
$slot['alt'] = __( 'Image kept from the original post', 'agentimus' );
}
if ( preg_match( '/wp-image-(\d+)/', $figure, $i ) ) {
$slot['attachment_id'] = (int) $i[1];
}
if ( preg_match_all( '#
,
,
" . esc_html( $text ) . "
\n"; } if ( XML_ELEMENT_NODE !== $node->nodeType ) { return ''; } $tag = strtolower( $node->nodeName ); switch ( $tag ) { case 'p': return "\n" . self::dom_inner_html( $node ) . "
\n"; case 'h2': return "\n" . trim( $loose ) . "
\n"; $loose = ''; } $inner .= "\n" . self::dom_inner_html( $child ) . "
\n"; } else { $loose .= $node->ownerDocument->saveHTML( $child ); } } if ( '' !== trim( $loose ) ) { $inner .= "\n" . trim( $loose ) . "
\n"; } return "\n" . $inner . "\n"; case 'figure': // Our injected image figure — recover the attachment id from the // wp-image-N class the editor itself uses. $img = $node->getElementsByTagName( 'img' )->item( 0 ); $id = 0; if ( $img && preg_match( '/wp-image-(\d+)/', (string) $img->getAttribute( 'class' ), $m ) ) { $id = (int) $m[1]; } $attrs = $id > 0 ? sprintf( ' {"id":%d,"sizeSlug":"large","linkDestination":"none"}', $id ) : ''; return '\n" . $node->ownerDocument->saveHTML( $node ) . "\n"; default: // Outside the contract's vocabulary: render verbatim through the // custom-HTML block — displays identically, still valid blocks. return "\n" . $node->ownerDocument->saveHTML( $node ) . "\n"; } } /** * The inner HTML of a DOM node, serialised by its own document. * * @param \DOMNode $node Node. * @return string */ private static function dom_inner_html( \DOMNode $node ) { $html = ''; foreach ( $node->childNodes as $child ) { $html .= $node->ownerDocument->saveHTML( $child ); } return $html; } /** * PURE: advance an insertion point past any figures already sitting there, * so figures injected one by one under the same anchor keep their order * instead of leap-frogging each other. * * @param string $content The content being injected into. * @param int $pos Candidate insertion offset. * @return int The adjusted offset. */ private static function skip_past_figures( $content, $pos ) { while ( preg_match( '#\A\s*