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)}}'; } }