post( '/ai-edit', array( $this, 'generate' ), array( 'post_id' => array( 'type' => 'integer', 'required' => true ), 'action' => array( 'type' => 'string', 'required' => true ), 'selection' => array( 'type' => 'string', 'required' => false, 'default' => '' ), 'selection_type' => array( 'type' => 'string', 'required' => false, 'default' => 'block' ), 'instruction' => array( 'type' => 'string', 'required' => false, 'default' => '' ), 'option' => array( 'type' => 'string', 'required' => false, 'default' => '' ), 'instruction_ids' => array( 'type' => 'array', 'required' => false, 'default' => array() ) ) ); } public function permission_check() { // Gate on edit_others_posts to match the sibling FAQ/Glossary AI endpoints // (AIFaq/AIGlossary) and keep Author-role users from spending the AI budget. return current_user_can( 'edit_others_posts' ); } public function generate( WP_REST_Request $request ) { $write_ai = betterdocs()->ai_autowrtie; if ( empty( $write_ai ) || ! $write_ai->isEnabledWriteWithAI() ) { return $this->error( 'ai_disabled', __( 'Write with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ), 400 ); } $api_key = $write_ai->get_api_key(); if ( empty( $api_key ) ) { return $this->error( 'ai_no_key', __( 'AI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ), 400 ); } $action = sanitize_key( (string) $request->get_param( 'action' ) ); $selection_type = sanitize_key( (string) $request->get_param( 'selection_type' ) ); $selection = (string) $request->get_param( 'selection' ); $instruction = (string) $request->get_param( 'instruction' ); $option = sanitize_text_field( (string) $request->get_param( 'option' ) ); if ( strlen( $selection ) > self::MAX_SELECTION_LENGTH ) { $selection = substr( $selection, 0, self::MAX_SELECTION_LENGTH ); } if ( strlen( $instruction ) > self::MAX_INSTRUCTION_LENGTH ) { $instruction = substr( $instruction, 0, self::MAX_INSTRUCTION_LENGTH ); } $instruction = wp_kses_post( $instruction ); // The selection is content to transform, sent verbatim in the OpenAI request // body (not rendered), and for block selections it is serialized Gutenberg // markup (`` delimiters + block HTML). wp_kses_post() would strip // anything off the post allowlist — inline SVG, embeds, data-* attributes — and // mangle block delimiters, so the model never sees them and they vanish on // Accept. Preserve the markup here; WordPress applies kses on the normal save // path per the user's capability. Only guard against malformed UTF-8. $selection = wp_check_invalid_utf8( $selection ); if ( 'inline' !== $selection_type ) { $selection_type = 'block'; } $presets = self::get_presets(); if ( 'custom' !== $action && ! isset( $presets[ $action ] ) ) { return $this->error( 'ai_bad_action', __( 'Unknown AI action.', 'betterdocs' ), 400 ); } if ( trim( wp_strip_all_tags( $instruction ) ) === '' ) { return $this->error( 'ai_empty_instruction', __( 'Please provide a prompt for the AI.', 'betterdocs' ), 400 ); } if ( trim( wp_strip_all_tags( $selection ) ) === '' && 'continue' !== $action ) { return $this->error( 'ai_empty_selection', __( 'No content selected for the AI to work on.', 'betterdocs' ), 400 ); } $prompt = $this->build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option ); // Selected instruction sets → extra system messages layered on the base prompt. $extra_system = $write_ai->get_instruction_messages( (array) $request->get_param( 'instruction_ids' ) ); $result = $write_ai->generate_openai_response_ai_edit( $prompt, $extra_system ); if ( empty( $result[ 'success' ] ) ) { $message = isset( $result[ 'error' ] ) ? (string) $result[ 'error' ] : __( 'Unknown AI error.', 'betterdocs' ); return $this->error( 'ai_upstream', $message, 502 ); } // Sanitize the model-generated HTML before it reaches the editor (rendered // via dangerouslySetInnerHTML in the Edit-with-AI preview): strip