settings = $settings;
}
/**
* The REST route registers on every request (REST is not is_admin()); the editor
* assets only in the admin.
*/
public function register() {
add_action( 'rest_api_init', array( $this, 'register_rest' ) );
if ( is_admin() ) {
add_action( 'admin_enqueue_scripts', array( $this, 'assets' ) );
}
}
/* ---------------------------------------------------------------------- *
* Availability
* ---------------------------------------------------------------------- */
/**
* Whether the site can generate text right now: the WP 7.0 AI Client is present,
* AI is not disabled for the request, and at least one registered provider is
* configured (has a key). Cheap enough for a per-editor-load gate; the REST handler
* still does the authoritative per-prompt {@see wp_ai_client_prompt()} capability
* check before spending anything.
*
* @return bool
*/
public static function ai_available() {
if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
return false;
}
if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) {
return false;
}
if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) {
return false;
}
try {
$registry = \WordPress\AiClient\AiClient::defaultRegistry();
foreach ( (array) $registry->getRegisteredProviderIds() as $id ) {
if ( $registry->isProviderConfigured( $id ) ) {
return true;
}
}
} catch ( \Throwable $e ) {
return false;
}
return false;
}
/** Which fields the assist can draft on this site (a field must have its feature on). */
private static function fields() {
return array(
'description' => Description::enabled(),
'topics' => Topics::enabled(),
);
}
/* ---------------------------------------------------------------------- *
* REST
* ---------------------------------------------------------------------- */
/**
* POST /agentimus/v1/suggest — draft one field for a post. Gated per post by
* edit_post, exactly like the meta the suggestion targets.
*/
public function register_rest() {
register_rest_route(
self::NS,
self::ROUTE,
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'suggest' ),
'permission_callback' => static function ( $request ) {
return current_user_can( 'edit_post', absint( $request['post'] ) );
},
'args' => array(
'post' => array(
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
// Validated in the callback (not via an `enum` here) so a subscriber is
// denied by the permission gate first — an enum would 400 before the 403,
// exactly what RestPermissionTest guards against.
'field' => array(
'type' => 'string',
'required' => true,
),
),
)
);
register_rest_route(
self::NS,
self::ROUTE_FIX,
array(
'methods' => \WP_REST_Server::CREATABLE,
'callback' => array( $this, 'suggest_fix' ),
'permission_callback' => static function ( $request ) {
return current_user_can( 'edit_post', absint( $request['post'] ) );
},
'args' => array(
'post' => array(
'type' => 'integer',
'required' => true,
'sanitize_callback' => 'absint',
),
// No `enum` here for the same reason as /suggest above.
'check' => array(
'type' => 'string',
'required' => true,
),
),
)
);
}
/**
* The AI-fixable readability checks (a subset of {@see PageCheck} ids), each with the
* prompt that drafts its fix and whether the result is safe to APPLY automatically.
* Only the opening summary is auto-applied — an additive paragraph. The rest are
* copy-paste suggestions: proposing headings, rewriting prose, or listing what to add
* are edits an author must own, not a bot rewriting the page. Checks not listed here
* (heading order, link density, freshness, alt text) are mechanical or need image
* understanding, so they get no button.
*
* @param string $check PageCheck id.
* @return array{system:string,user:string,apply:?string}|null
*/
private static function fix_spec( $check ) {
$specs = array(
'summary' => array(
'system' => __( 'You write the opening summary for a web page: one clear sentence (two at most) stating what the page is about, so an AI assistant can grab the gist. Plain text only — no markdown, no quotation marks, no preamble. Use only what the content supports.', 'agentimus' ),
'user' => "Title: %s\n\nContent:\n%s\n\nWrite the opening summary.",
'apply' => 'prependParagraph',
),
'headings' => array(
'system' => __( 'You propose a section-heading outline so an AI can navigate a page. Return only the headings, one per line, each prefixed with "## " for a section or "### " for a subsection. Base them on the actual content. No preamble, no prose.', 'agentimus' ),
'user' => "Title: %s\n\nContent:\n%s\n\nPropose the heading outline.",
'apply' => null,
),
'passages' => array(
'system' => __( 'You improve quotability. Find the single longest, densest paragraph in the content and rewrite it as two or three shorter, self-contained paragraphs that keep the meaning. Return only the rewritten paragraphs as plain text separated by blank lines. No preamble.', 'agentimus' ),
'user' => "Title: %s\n\nContent:\n%s\n\nRewrite the longest paragraph into shorter ones.",
'apply' => null,
),
'evidence' => array(
'system' => __( 'You make a page more citable. Suggest two or three concrete specifics it could add — a statistic, a concrete example, or a credible type of source to cite — grounded in its topic. Return a short list, each line starting with "- ". Do not invent fake figures; say what kind of specific to add and where.', 'agentimus' ),
'user' => "Title: %s\n\nContent:\n%s\n\nSuggest concrete specifics to add.",
'apply' => null,
),
'words' => array(
'system' => __( 'A web page is too thin to be citable. Propose a brief outline of what to ADD to make it substantive — three to five bullet points of missing sections or questions to answer. Return only the bulleted outline, each line starting with "- ". Do NOT write filler prose.', 'agentimus' ),
'user' => "Title: %s\n\nContent:\n%s\n\nOutline what to add.",
'apply' => null,
),
);
return isset( $specs[ $check ] ) ? $specs[ $check ] : null;
}
/**
* Whether a readability check can be drafted by the assist.
*
* @param string $check PageCheck id.
* @return bool
*/
public static function fixable( $check ) {
return null !== self::fix_spec( (string) $check );
}
/**
* POST /agentimus/v1/suggest-fix — draft a concrete fix for one warn/fail readability
* check. Returns { check, suggestion, apply } where apply is a safe, additive editor
* action (only the opening summary) or null for a copy-paste suggestion.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function suggest_fix( \WP_REST_Request $request ) {
$check = (string) $request['check'];
$spec = self::fix_spec( $check );
if ( null === $spec ) {
return new \WP_Error( 'rest_invalid_param', __( 'That check can’t be drafted by AI.', 'agentimus' ), array( 'status' => 400 ) );
}
$post = get_post( absint( $request['post'] ) );
if ( ! $post || ! in_array( $post->post_type, Content::post_types(), true ) ) {
return new \WP_Error( 'agentimus_not_found', __( 'That content is not available.', 'agentimus' ), array( 'status' => 404 ) );
}
if ( ! $this->settings->enabled( 'enable_page_checks' ) ) {
return new \WP_Error( 'agentimus_field_off', __( 'Readability is turned off.', 'agentimus' ), array( 'status' => 400 ) );
}
if ( ! self::ai_available() ) {
return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI provider is configured. Add one under Settings → AI.', 'agentimus' ), array( 'status' => 503 ) );
}
$ctx = $this->context( $post );
if ( '' === $ctx['title'] && '' === $ctx['body'] ) {
return new \WP_Error( 'agentimus_thin', __( 'This page has too little content to draft from yet.', 'agentimus' ), array( 'status' => 422 ) );
}
$text = $this->generate( $spec['system'], sprintf( $spec['user'], $ctx['title'], $ctx['body'] ), self::OUTPUT_TOKENS );
if ( is_wp_error( $text ) ) {
return $text;
}
$text = trim( $text );
$apply = null;
if ( 'prependParagraph' === $spec['apply'] ) {
// The opening summary is applied as a single clean paragraph — run it through the
// description cleaner (tags out, one line, capped) so what we insert is safe.
$text = Description::clean( trim( $text, " \t\n\r\0\x0B\"'“”‘’" ) );
$apply = array(
'type' => 'prependParagraph',
'content' => $text,
);
}
return rest_ensure_response(
array(
'check' => $check,
'suggestion' => $text,
'apply' => $apply,
)
);
}
/**
* Draft the requested field. Returns { field, text } for a description or
* { field, topics: [...] } for topics, or a WP_Error the editor surfaces inline.
*
* @param \WP_REST_Request $request Request.
* @return \WP_REST_Response|\WP_Error
*/
public function suggest( \WP_REST_Request $request ) {
$field = (string) $request['field'];
if ( ! in_array( $field, array( 'description', 'topics' ), true ) ) {
return new \WP_Error( 'rest_invalid_param', __( 'Unknown field to draft.', 'agentimus' ), array( 'status' => 400 ) );
}
$post = get_post( absint( $request['post'] ) );
if ( ! $post || ! in_array( $post->post_type, Content::post_types(), true ) ) {
return new \WP_Error( 'agentimus_not_found', __( 'That content is not available.', 'agentimus' ), array( 'status' => 404 ) );
}
if ( ( 'description' === $field && ! Description::enabled() ) || ( 'topics' === $field && ! Topics::enabled() ) ) {
return new \WP_Error( 'agentimus_field_off', __( 'That feature is turned off.', 'agentimus' ), array( 'status' => 400 ) );
}
if ( ! self::ai_available() ) {
return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI provider is configured. Add one under Settings → AI.', 'agentimus' ), array( 'status' => 503 ) );
}
$ctx = $this->context( $post );
if ( '' === $ctx['title'] && '' === $ctx['body'] ) {
return new \WP_Error( 'agentimus_thin', __( 'This page has too little content to draft from yet. Add some content and try again.', 'agentimus' ), array( 'status' => 422 ) );
}
return ( 'topics' === $field )
? $this->suggest_topics( $ctx )
: $this->suggest_description( $ctx );
}
/**
* The page context handed to the model: its title and a cleaned, capped body. Uses
* {@see Content::markdown_source()} (robust even where the rendered body is filtered
* off-loop), falling back to the raw stripped content.
*
* @param \WP_Post $post Post.
* @return array{title:string,body:string}
*/
private function context( \WP_Post $post ) {
$title = Worklist::title_of( $post );
// markdown_source() returns the page's HTML (the source the .md converter reads); the
// raw content is the off-loop-safe fallback. Either way, strip to plain text so the
// model gets clean prose, not tag noise that just burns tokens.
$body = (string) Content::markdown_source( $post );
if ( '' === trim( $body ) ) {
$body = (string) $post->post_content;
}
$body = wp_strip_all_tags( $body );
$body = trim( (string) preg_replace( '/\s+/', ' ', $body ) );
$body = function_exists( 'mb_substr' ) ? mb_substr( $body, 0, self::MAX_CONTEXT_CHARS ) : substr( $body, 0, self::MAX_CONTEXT_CHARS );
return array(
'title' => trim( $title ),
'body' => trim( $body ),
);
}
/**
* Draft the one-line AI description.
*
* @param array{title:string,body:string} $ctx Page context.
* @return \WP_REST_Response|\WP_Error
*/
private function suggest_description( array $ctx ) {
$system = __( 'You write a single, plain-language sentence describing a web page so an AI answer engine can summarise and cite it correctly. Output ONE sentence only: no quotation marks, no markdown, no line breaks, and about 160 characters or fewer. State what the page is and who it is for — do not invent facts that are not in the content.', 'agentimus' );
$prompt = sprintf( "Title: %s\n\nContent:\n%s\n\nWrite the one-sentence description.", $ctx['title'], $ctx['body'] );
$text = $this->generate( $system, $prompt, self::OUTPUT_TOKENS );
if ( is_wp_error( $text ) ) {
return $text;
}
// Strip any wrapping quotes the model added, then run the same cleaner the field's
// own save path uses (tags out, whitespace collapsed, capped).
$text = Description::clean( trim( $text, " \t\n\r\0\x0B\"'“”‘’" ) );
return rest_ensure_response( array( 'field' => 'description', 'text' => $text ) );
}
/**
* Draft the topic keyword list.
*
* @param array{title:string,body:string} $ctx Page context.
* @return \WP_REST_Response|\WP_Error
*/
private function suggest_topics( array $ctx ) {
$cap = Topics::cap();
$system = sprintf(
/* translators: %d: the maximum number of topics. */
__( 'You extract what a web page is about as short topic keywords for AI answer engines. Return a plain comma-separated list of at most %d topics, each one to four words, lower-case unless a proper noun. No numbering, no sentences, no explanation — just the comma-separated list. Only use topics grounded in the content.', 'agentimus' ),
$cap
);
$prompt = sprintf( "Title: %s\n\nContent:\n%s\n\nList the topics.", $ctx['title'], $ctx['body'] );
$text = $this->generate( $system, $prompt, self::OUTPUT_TOKENS );
if ( is_wp_error( $text ) ) {
return $text;
}
// Parse + sanitise through the field's own sanitiser (splits on commas/newlines,
// trims, dedupes case-insensitively, caps each length and the count).
$topics = Topics::sanitize_manual( $text );
return rest_ensure_response( array( 'field' => 'topics', 'topics' => array_values( (array) $topics ) ) );
}
/**
* Run one text generation through the site's configured AI, returning the trimmed
* string or a WP_Error (already carrying an HTTP status) for the editor to show.
*
* @param string $system System instruction.
* @param string $prompt User prompt.
* @param int $max_tokens Output cap.
* @return string|\WP_Error
*/
public function generate( $system, $prompt, $max_tokens ) {
// Public since the in-admin Assistant: this is the ONE choke point every
// Agentimus generation goes through — it carries the per-user rate limit and
// the site's Content Guidelines, so a new caller gets both for free and a
// bypassed budget can't exist.
if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
return new \WP_Error( 'agentimus_ai_unavailable', __( 'AI is not available in this environment.', 'agentimus' ), array( 'status' => 503 ) );
}
// The site's Content Guidelines (WP's experimental editorial-rules feature),
// when present, ride every generation so drafts land in the site's own voice.
$guidelines = Guidelines::digest();
if ( '' !== $guidelines ) {
$system = "Site content guidelines — follow these where relevant:\n" . $guidelines . "\n\n" . $system;
}
$builder = wp_ai_client_prompt( $prompt )
->using_system_instruction( $system )
->using_max_tokens( (int) $max_tokens )
->using_temperature( 0.4 );
// Authoritative capability check: false when no configured model can do text
// generation — surface it as a clean 503 rather than letting generate_text() fail.
if ( ! $builder->is_supported_for_text_generation() ) {
return new \WP_Error( 'agentimus_ai_unavailable', __( 'No AI text model is configured. Add or enable one under Settings → AI.', 'agentimus' ), array( 'status' => 503 ) );
}
// Spend backstop, checked immediately BEFORE the one paid call this method makes. Both
// Assist routes gate only on edit_post, so any Contributor+ (or a compromised such account)
// holds a valid nonce and could otherwise script unbounded — and parallel — POSTs, each
// firing a real model call: a financial DoS on the owner's AI bill. A human clicking
// "Draft"/"Fix" never approaches the cap; a script is bounded to a drip the owner can notice.
// The Visibility Runner already carries an equivalent backstop (single-flight + a call
// ceiling); this brings the edit-time path up to the same footing.
if ( self::rate_limited() ) {
return new \WP_Error(
'agentimus_ai_rate_limited',
__( 'Too many AI requests in a short time. Please wait a moment and try again.', 'agentimus' ),
array( 'status' => 429 )
);
}
$text = $builder->generate_text();
if ( is_wp_error( $text ) ) {
return $text; // Already a WP_Error with an HTTP status from the AI Client.
}
$text = is_string( $text ) ? trim( $text ) : '';
if ( '' === $text ) {
return new \WP_Error( 'agentimus_ai_empty', __( 'The AI returned an empty response — try again, and if it keeps happening, check the provider key under Settings → AI.', 'agentimus' ), array( 'status' => 502 ) );
}
return $text;
}
/**
* Whether the current user has spent this window's Assist call budget. A coarse per-user,
* per-window transient counter — the same shape as the activity flood cap. Approximate under
* heavy parallelism (transients are not atomic), which is fine: it converts an UNBOUNDED
* parallel burst into a small bounded multiple, which is the whole point. Counts only calls that
* reach here — i.e. that are about to fire a paid request.
*
* @return bool True when over budget (the caller returns 429).
*/
private static function rate_limited() {
$user = get_current_user_id();
if ( $user <= 0 ) {
return false; // No anonymous path — the routes require edit_post — but fail open if so.
}
/**
* Filter the per-user Assist call budget (paid AI calls per minute). 0 disables the cap.
*
* @param int $max Calls allowed per window.
*/
$max = (int) apply_filters( 'agentimus_assist_rate_max', self::ASSIST_RATE_MAX );
if ( $max < 1 ) {
return false;
}
$key = 'agentimus_assist_rate_' . $user . '_' . (int) floor( time() / self::ASSIST_RATE_WINDOW );
$count = (int) get_transient( $key );
if ( $count >= $max ) {
return true;
}
set_transient( $key, $count + 1, self::ASSIST_RATE_WINDOW * 2 );
return false;
}
/* ---------------------------------------------------------------------- *
* Editor assets
* ---------------------------------------------------------------------- */
/**
* Enqueue the button injector — only on the editor for a covered post type, only
* when AI is configured, and only for the fields whose feature is on.
*
* @param string $hook Current admin page hook.
*/
public function assets( $hook ) {
if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) {
return;
}
$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;
if ( ! $screen || ! in_array( $screen->post_type, Content::post_types(), true ) ) {
return;
}
$fields = array_filter( self::fields() );
$page_checks = (bool) $this->settings->enabled( 'enable_page_checks' );
if ( empty( $fields ) && ! $page_checks ) {
return;
}
/**
* Whether the "Draft with AI" editor assist is offered. Defaults to on when a
* text-capable AI provider is configured; return false to hide it regardless.
*
* @param bool $enabled Whether to offer the assist.
*/
if ( ! (bool) apply_filters( 'agentimus_ai_assist_enabled', self::ai_available() ) ) {
return;
}
$config = array(
'endpoint' => esc_url_raw( rest_url( self::NS . self::ROUTE ) ),
'endpointFix' => esc_url_raw( rest_url( self::NS . self::ROUTE_FIX ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'fields' => array(
'description' => ! empty( $fields['description'] ),
'topics' => ! empty( $fields['topics'] ),
),
'readability' => $page_checks,
'i18n' => array(
'draft' => __( 'Draft with AI', 'agentimus' ),
'suggest' => __( 'Suggest with AI', 'agentimus' ),
'working' => __( 'Drafting…', 'agentimus' ),
'needsSave' => __( 'Save a draft first, then try again.', 'agentimus' ),
'error' => __( 'Couldn’t draft — check your AI provider under Settings → AI.', 'agentimus' ),
'apply' => __( 'Apply', 'agentimus' ),
'applied' => __( 'Applied ✓', 'agentimus' ),
'copy' => __( 'Copy', 'agentimus' ),
'copied' => __( 'Copied ✓', 'agentimus' ),
'copyFailed' => __( 'Press ⌘/Ctrl+C to copy', 'agentimus' ),
),
);
wp_register_script( self::HANDLE, false, array(), AGENTIMUS_VERSION, true );
wp_enqueue_script( self::HANDLE );
wp_add_inline_script( self::HANDLE, 'window.agentimusAssist=' . wp_json_encode( $config ) . ';', 'before' );
wp_add_inline_script( self::HANDLE, self::inline_js() );
wp_register_style( self::HANDLE, false, array(), AGENTIMUS_VERSION );
wp_enqueue_style( self::HANDLE );
wp_add_inline_style( self::HANDLE, self::inline_css() );
}
/**
* The button injector. Adds a "Draft with AI" control to the description box and a
* "Suggest with AI" control to the topics box; on click it calls the REST route and
* drops the result into the field (the description textarea directly; topics via a
* custom event the chip editor listens for). Never saves — the author still saves the
* post. Vanilla JS, no build step, matching the other editor enhancements.
*
* @return string
*/
private static function inline_js() {
return <<<'JS'
(function(){
var CFG = window.agentimusAssist || {};
if (!CFG.endpoint) { return; }
var SPARK = '';
function postId(){
try {
if (window.wp && wp.data && wp.data.select) {
var ed = wp.data.select('core/editor');
if (ed && ed.getCurrentPostId) { var id = ed.getCurrentPostId(); if (id) { return id; } }
}
} catch (e) {}
var el = document.getElementById('post_ID');
return el ? parseInt(el.value, 10) : 0;
}
function request(field, btn, note, done){
var id = postId();
if (!id) { note.textContent = CFG.i18n.needsSave; note.className = 'agentimus-assist__note is-error'; return; }
btn.disabled = true; btn.classList.add('is-busy');
note.textContent = CFG.i18n.working; note.className = 'agentimus-assist__note';
fetch(CFG.endpoint, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': CFG.nonce },
body: JSON.stringify({ post: id, field: field })
}).then(function(r){
return r.json().then(function(j){ return { ok: r.ok, body: j }; });
}).then(function(res){
btn.disabled = false; btn.classList.remove('is-busy');
if (!res.ok) {
note.textContent = (res.body && res.body.message) ? res.body.message : CFG.i18n.error;
note.className = 'agentimus-assist__note is-error';
return;
}
note.textContent = '';
done(res.body);
}).catch(function(){
btn.disabled = false; btn.classList.remove('is-busy');
note.textContent = CFG.i18n.error; note.className = 'agentimus-assist__note is-error';
});
}
function makeControl(label, onClick){
var wrap = document.createElement('div'); wrap.className = 'agentimus-assist';
var btn = document.createElement('button');
btn.type = 'button'; btn.className = 'button button-small agentimus-assist__btn';
btn.innerHTML = SPARK + '' + label + '';
var note = document.createElement('span');
note.className = 'agentimus-assist__note'; note.setAttribute('aria-live', 'polite');
btn.addEventListener('click', function(){ onClick(btn, note); });
wrap.appendChild(btn); wrap.appendChild(note);
return wrap;
}
function enhanceDescription(){
var box = document.querySelector('.agentimus-desc');
if (!box || box.querySelector('.agentimus-assist')) { return true; }
var ta = box.querySelector('.agentimus-desc__input');
if (!ta) { return false; }
var ctrl = makeControl(CFG.i18n.draft, function(btn, note){
request('description', btn, note, function(body){
if (typeof body.text === 'string' && body.text) {
ta.value = body.text;
ta.dispatchEvent(new Event('input', { bubbles: true }));
ta.focus();
}
});
});
ta.insertAdjacentElement('afterend', ctrl);
return true;
}
function enhanceTopics(){
var box = document.querySelector('.agentimus-topics');
if (!box || box.querySelector('.agentimus-assist')) { return true; }
var chips = box.querySelector('.agentimus-topics__chips');
var ctrl = makeControl(CFG.i18n.suggest, function(btn, note){
request('topics', btn, note, function(body){
if (Array.isArray(body.topics) && body.topics.length) {
box.dispatchEvent(new CustomEvent('agentimus-topics:add', { detail: { topics: body.topics } }));
}
});
});
if (chips) { chips.insertAdjacentElement('afterend', ctrl); } else { box.appendChild(ctrl); }
return true;
}
// ---- Readability: "Fix with AI" on warn/fail rows ----
// The readability panel is server-rendered AND re-rendered over REST after each save, so
// the click is delegated on the document (survives innerHTML replacement) not bound per button.
function canInsertBlocks(){
try { return !!(window.wp && wp.data && wp.blocks && wp.blocks.createBlock && wp.data.dispatch('core/block-editor')); }
catch (e) { return false; }
}
function prependParagraph(content){
try {
wp.data.dispatch('core/block-editor').insertBlocks(wp.blocks.createBlock('core/paragraph', { content: content }), 0);
return true;
} catch (e) { return false; }
}
function mkBtn(label, onClick){
var b = document.createElement('button');
b.type = 'button'; b.className = 'button button-small'; b.textContent = label;
b.addEventListener('click', onClick);
return b;
}
/**
* navigator.clipboard only exists in a SECURE context (HTTPS or localhost). On a
* plain-HTTP site it is undefined, so guarding on it and doing nothing else — which is
* what this used to do — left the Copy button silently dead. Fall back to the legacy
* textarea + execCommand path, exactly like the rest of the admin (see uaTip.js), and
* always report the outcome rather than failing in silence.
*/
function copyText(text){
if (!text) { return Promise.resolve(false); }
var legacy = function(){
try {
var ta = document.createElement('textarea');
ta.value = text;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed'; ta.style.top = '-1000px'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch (e) { return false; }
};
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text).then(function(){ return true; }, function(){ return legacy(); });
}
return Promise.resolve(legacy());
}
function renderFix(out, body){
out.innerHTML = ''; out.className = 'agentimus-pc__fixout'; out.hidden = false;
var text = (body && typeof body.suggestion === 'string') ? body.suggestion : '';
var pre = document.createElement('div'); pre.className = 'agentimus-pc__suggestion'; pre.textContent = text;
out.appendChild(pre);
var actions = document.createElement('div'); actions.className = 'agentimus-pc__fixactions';
if (body && body.apply && body.apply.type === 'prependParagraph' && canInsertBlocks()) {
var applyBtn = mkBtn(CFG.i18n.apply, function(){
if (prependParagraph(body.apply.content)) { applyBtn.textContent = CFG.i18n.applied; applyBtn.disabled = true; }
});
actions.appendChild(applyBtn);
}
var copyBtn = mkBtn(CFG.i18n.copy, function(){
copyText(text).then(function(ok){
copyBtn.textContent = ok ? CFG.i18n.copied : CFG.i18n.copyFailed;
});
});
actions.appendChild(copyBtn);
out.appendChild(actions);
}
function handleFix(btn){
var check = btn.getAttribute('data-check');
var row = btn.closest('.agentimus-pc__row');
var container = btn.closest('.agentimus-pc');
var out = row ? row.querySelector('.agentimus-pc__fixout') : null;
var id = (container && container.getAttribute('data-post')) ? parseInt(container.getAttribute('data-post'), 10) : postId();
if (!id) { if (out) { out.hidden = false; out.className = 'agentimus-pc__fixout is-error'; out.textContent = CFG.i18n.needsSave; } return; }
btn.disabled = true; btn.classList.add('is-busy');
if (out) { out.hidden = false; out.className = 'agentimus-pc__fixout'; out.textContent = CFG.i18n.working; }
fetch(CFG.endpointFix, {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': CFG.nonce },
body: JSON.stringify({ post: id, check: check })
}).then(function(r){ return r.json().then(function(j){ return { ok: r.ok, body: j }; }); })
.then(function(res){
btn.disabled = false; btn.classList.remove('is-busy');
if (!res.ok) { if (out) { out.hidden = false; out.className = 'agentimus-pc__fixout is-error'; out.textContent = (res.body && res.body.message) ? res.body.message : CFG.i18n.error; } return; }
if (out) { renderFix(out, res.body); }
}).catch(function(){
btn.disabled = false; btn.classList.remove('is-busy');
if (out) { out.hidden = false; out.className = 'agentimus-pc__fixout is-error'; out.textContent = CFG.i18n.error; }
});
}
if (CFG.readability) {
document.addEventListener('click', function(e){
var btn = e.target && e.target.closest ? e.target.closest('.agentimus-pc__fix') : null;
if (btn) { handleFix(btn); }
});
}
// The meta boxes are server-rendered but the block editor relocates them after load,
// so retry a few times until each target is found (then stop). Mirrors the retry the
// topics live-sync uses.
function run(tries){
var done = true;
if (CFG.fields && CFG.fields.description) { done = enhanceDescription() && done; }
if (CFG.fields && CFG.fields.topics) { done = enhanceTopics() && done; }
if (!done && tries > 0) { setTimeout(function(){ run(tries - 1); }, 300); }
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function(){ run(20); });
} else {
run(20);
}
})();
JS;
}
/**
* Styles for the assist control — WordPress-admin palette, scoped to our wrapper.
*
* @return string
*/
private static function inline_css() {
return '.agentimus-assist{display:flex;flex-wrap:wrap;align-items:stretch;gap:6px 8px;margin:8px 0 0}'
. '.agentimus-assist__btn{display:flex;width:100%;align-items:center;justify-content:center;gap:5px}'
. '.agentimus-assist__btn .agentimus-assist__icon{color:#8073a6;flex:none}'
. '.agentimus-assist__btn.is-busy{opacity:.7;cursor:progress}'
. '.agentimus-assist__btn.is-busy .agentimus-assist__icon{animation:agentimus-assist-spin 1s linear infinite}'
. '.agentimus-assist__note{font-size:12px;color:#646970;line-height:1.5}'
. '.agentimus-assist__note.is-error{color:#d63638}'
// Readability "Fix with AI" affordance.
. '.agentimus-pc__fix{display:inline-flex;align-items:center;gap:4px;margin-top:6px;background:none;border:1px solid #c3c4c7;border-radius:3px;padding:2px 8px;font-size:12px;color:#2271b1;cursor:pointer}'
. '.agentimus-pc__fix:hover{border-color:#2271b1;background:#f6f7f7}'
. '.agentimus-pc__fix .agentimus-assist__icon{color:#8073a6}'
. '.agentimus-pc__fix.is-busy{opacity:.7;cursor:progress}'
. '.agentimus-pc__fix.is-busy .agentimus-assist__icon{animation:agentimus-assist-spin 1s linear infinite}'
. '.agentimus-pc__fixout{margin-top:8px;font-size:12px;color:#646970}'
. '.agentimus-pc__fixout.is-error{color:#d63638}'
. '.agentimus-pc__suggestion{white-space:pre-wrap;background:#f6f7f7;border-left:3px solid #8073a6;border-radius:2px;padding:8px 10px;color:#2c3338;line-height:1.5}'
. '.agentimus-pc__fixactions{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}'
. '@keyframes agentimus-assist-spin{to{transform:rotate(360deg)}}';
}
}