PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/REST/AIEdit.php +206 -48 4.5.54.9.2 View file →
@@ -3,8 +3,9 @@
3 3 namespace WPDeveloper\BetterDocs\REST;
4 4
5 5 use WP_REST_Request;
6 6 use WPDeveloper\BetterDocs\Core\BaseAPI;
7 +use WPDeveloper\BetterDocs\Utils\AIUsage;
7 8
8 9 class AIEdit extends BaseAPI {
9 10
10 11 const MAX_SELECTION_LENGTH = 12000;
@@ -41,8 +42,13 @@
41 42 'option' => array(
42 43 'type' => 'string',
43 44 'required' => false,
44 45 'default' => ''
46 + ),
47 + 'instruction_ids' => array(
48 + 'type' => 'array',
49 + 'required' => false,
50 + 'default' => array()
45 51 )
46 52 )
47 53 );
48 54 }
@@ -47,15 +53,11 @@
47 53 );
48 54 }
49 55
50 56 public function permission_check() {
51 - $post_id = isset( $_REQUEST[ 'post_id' ] ) ? intval( $_REQUEST[ 'post_id' ] ) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- REST permission_check; read-only id, request auth handled by the route's permission_callback.
52 -
53 - if ( $post_id <= 0 ) {
54 - return current_user_can( 'edit_posts' );
55 - }
56 -
57 - return current_user_can( 'edit_post', $post_id );
57 + // Gate on edit_others_posts to match the sibling FAQ/Glossary AI endpoints
58 + // (AIFaq/AIGlossary) and keep Author-role users from spending the AI budget.
59 + return current_user_can( 'edit_others_posts' );
58 60 }
59 61
60 62 public function generate( WP_REST_Request $request ) {
61 63 $write_ai = betterdocs()->ai_autowrtie;
@@ -71,9 +73,9 @@
71 73 $api_key = $write_ai->get_api_key();
72 74 if ( empty( $api_key ) ) {
73 75 return $this->error(
74 76 'ai_no_key',
75 - __( 'OpenAI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
77 + __( 'AI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
76 78 400
77 79 );
78 80 }
79 81
@@ -90,10 +92,18 @@
90 92 $instruction = substr( $instruction, 0, self::MAX_INSTRUCTION_LENGTH );
91 93 }
92 94
93 95 $instruction = wp_kses_post( $instruction );
94 - $selection = wp_kses_post( $selection );
95 96
97 + // The selection is content to transform, sent verbatim in the OpenAI request
98 + // body (not rendered), and for block selections it is serialized Gutenberg
99 + // markup (`<!-- wp:… -->` delimiters + block HTML). wp_kses_post() would strip
100 + // anything off the post allowlist — inline SVG, embeds, data-* attributes — and
101 + // mangle block delimiters, so the model never sees them and they vanish on
102 + // Accept. Preserve the markup here; WordPress applies kses on the normal save
103 + // path per the user's capability. Only guard against malformed UTF-8.
104 + $selection = wp_check_invalid_utf8( $selection );
105 +
96 106 if ( 'inline' !== $selection_type ) {
97 107 $selection_type = 'block';
98 108 }
99 109
@@ -124,16 +134,22 @@
124 134 }
125 135
126 136 $prompt = $this->build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option );
127 137
128 - $result = $write_ai->generate_openai_response_ai_edit( $prompt );
138 + // Selected instruction sets → extra system messages layered on the base prompt.
139 + $extra_system = $write_ai->get_instruction_messages( (array) $request->get_param( 'instruction_ids' ) );
129 140
141 + $result = $write_ai->generate_openai_response_ai_edit( $prompt, $extra_system );
142 +
130 143 if ( empty( $result[ 'success' ] ) ) {
131 144 $message = isset( $result[ 'error' ] ) ? (string) $result[ 'error' ] : __( 'Unknown AI error.', 'betterdocs' );
132 145 return $this->error( 'ai_upstream', $message, 502 );
133 146 }
134 147
135 - $content = $this->clean_output( (string) $result[ 'content' ] );
148 + // Sanitize the model-generated HTML before it reaches the editor (rendered
149 + // via dangerouslySetInnerHTML in the Edit-with-AI preview): strip <script>,
150 + // event handlers, <iframe> and javascript: URLs while keeping valid markup.
151 + $content = wp_kses_post( $this->clean_output( (string) $result[ 'content' ] ) );
136 152
137 153 if ( '' === $content ) {
138 154 return $this->error(
139 155 'ai_empty_response',
@@ -141,8 +157,10 @@
141 157 502
142 158 );
143 159 }
144 160
161 + AIUsage::record( 'ai_edit', (int) $request->get_param( 'post_id' ), $action );
162 +
145 163 return $this->success(
146 164 array(
147 165 'content' => $content,
148 166 'action' => $action,
@@ -175,51 +193,191 @@
175 193
176 194 protected function clean_output( $content ) {
177 195 $content = trim( $content );
178 196
179 - $content = preg_replace( '/^```(?:html|HTML)?\s*\n?/', '', $content );
197 + // Strip a leading fence with any info-string (```html, ```markdown, ```jsx,
198 + // ```text, or a bare ```), not just ```html — otherwise the info-string line
199 + // leaks into the block as literal text.
200 + $content = preg_replace( '/^```[a-z]*\s*\n?/i', '', $content );
180 201 $content = preg_replace( '/\n?```\s*$/', '', $content );
181 202
182 203 return trim( $content );
183 204 }
184 205
185 - public static function get_presets() {
206 + /**
207 + * The built-in Edit-with-AI actions, in display order.
208 + *
209 + * Single source of truth for the action chips (modal) and the prompt presets
210 + * (server). Each entry is the editable shape persisted under the `ai_edit_actions`
211 + * setting: { id, label, instruction, enabled, predefined }. Server-only metadata
212 + * (tone/language option, table formatting) lives in {@see self::predefined_extras()}
213 + * and is merged back in at resolve time — it is never stored or user-editable.
214 + *
215 + * @return array<int,array{id:string,label:string,instruction:string,enabled:bool,predefined:bool}>
216 + */
217 + public static function default_actions() {
218 + $defaults = array(
219 + 'improve' => array( __( 'Improve Writing', 'betterdocs' ), 'Improve the writing quality, clarity, grammar, and structure of the content below while preserving its original meaning and intent.' ),
220 + 'shorten' => array( __( 'Make Shorter', 'betterdocs' ), 'Make the content below more concise. Preserve all essential information; remove redundancy and filler.' ),
221 + 'expand' => array( __( 'Make Longer', 'betterdocs' ), 'Expand the content below with more detail, examples, and clear explanations. Keep the original voice.' ),
222 + 'simplify' => array( __( 'Simplify', 'betterdocs' ), 'Rewrite the content below in simpler language so it is easy to understand for a non-technical reader.' ),
223 + 'fix_grammar' => array( __( 'Fix Grammar', 'betterdocs' ), 'Fix only the grammar, spelling, and punctuation in the content below. Do not rewrite or change the meaning or style.' ),
224 + 'change_tone' => array( __( 'Change Tone', 'betterdocs' ), 'Rewrite the content below in a {option} tone while preserving its meaning.' ),
225 + 'translate' => array( __( 'Translate', 'betterdocs' ), 'Translate the content below into {option}. Preserve meaning, tone, and any HTML structure.' ),
226 + 'summarize' => array( __( 'Summarize', 'betterdocs' ), 'Summarize the content below into 2 to 3 clear sentences.' ),
227 + 'create_table' => array( __( 'Create Table', 'betterdocs' ), 'Extract the statistics, figures, or comparable data from the content below and represent them as a single HTML table. Infer clear column headers. Include every distinct data point. If no tabular data can be reasonably inferred, return the best possible structured representation.' ),
228 + );
229 +
230 + $actions = array();
231 + foreach ( $defaults as $id => $pair ) {
232 + $actions[] = array(
233 + 'id' => $id,
234 + 'label' => $pair[0],
235 + 'instruction' => $pair[1],
236 + 'enabled' => true,
237 + 'predefined' => true,
238 + );
239 + }
240 +
241 + return $actions;
242 + }
243 +
244 + /**
245 + * Server-only metadata for the predefined actions, keyed by id.
246 + *
247 + * Never persisted and not user-editable — merged into resolved presets so that
248 + * `change_tone`/`translate` keep their {option} substitution and `create_table`
249 + * keeps its single-<table> formatting contract.
250 + *
251 + * @return array<string,array{needs_option?:string,structural?:bool,format?:string}>
252 + */
253 + private static function predefined_extras() {
186 254 return array(
187 - 'improve' => array(
188 - 'prompt' => 'Improve the writing quality, clarity, grammar, and structure of the content below while preserving its original meaning and intent.'
255 + 'change_tone' => array( 'needs_option' => 'tone' ),
256 + 'translate' => array( 'needs_option' => 'language' ),
257 + 'create_table' => array(
258 + 'structural' => true,
259 + 'format' => 'Return only one complete <table> element with <thead> and <tbody>. Use <th scope="col"> for header cells. Do not include <style>, <script>, inline CSS classes, comments, explanations, preambles, code fences, or markdown.',
189 260 ),
190 - 'rewrite' => array(
191 - 'prompt' => 'Rewrite the content below with different wording while keeping the same meaning, tone, and level of detail.'
192 - ),
193 - 'shorten' => array(
194 - 'prompt' => 'Make the content below more concise. Preserve all essential information; remove redundancy and filler.'
195 - ),
196 - 'expand' => array(
197 - 'prompt' => 'Expand the content below with more detail, examples, and clear explanations. Keep the original voice.'
198 - ),
199 - 'simplify' => array(
200 - 'prompt' => 'Rewrite the content below in simpler language so it is easy to understand for a non-technical reader.'
201 - ),
202 - 'fix_grammar' => array(
203 - 'prompt' => 'Fix only the grammar, spelling, and punctuation in the content below. Do not rewrite or change the meaning or style.'
204 - ),
205 - 'change_tone' => array(
206 - 'prompt' => 'Rewrite the content below in a {option} tone while preserving its meaning.',
207 - 'needs_option' => true
208 - ),
209 - 'translate' => array(
210 - 'prompt' => 'Translate the content below into {option}. Preserve meaning, tone, and any HTML structure.',
211 - 'needs_option' => true
212 - ),
213 - 'summarize' => array(
214 - 'prompt' => 'Summarize the content below into 2 to 3 clear sentences.'
215 - ),
216 - 'continue' => array(
217 - 'prompt' => 'Continue writing naturally from where the content below ends. Match its tone, style, and level of detail.'
218 - ),
219 - 'create_table' => array(
220 - 'prompt' => 'Extract the statistics, figures, or comparable data from the content below and represent them as a single HTML table. Infer clear column headers. Include every distinct data point. If no tabular data can be reasonably inferred, return the best possible structured representation.',
221 - 'format' => 'Return only one complete <table> element with <thead> and <tbody>. Use <th scope="col"> for header cells. Do not include <style>, <script>, inline CSS classes, comments, explanations, preambles, code fences, or markdown.'
222 - )
223 261 );
262 + }
263 +
264 + /**
265 + * The resolved action list: stored overrides merged onto {@see self::default_actions()}.
266 + *
267 + * Predefined actions always appear (so newly shipped ones surface on sites with an
268 + * older stored value) carrying any saved enabled/label/instruction overrides; custom
269 + * (predefined:false) actions are appended in their stored order. Mirrors the
270 + * normalize-then-guarantee pattern of {@see \WPDeveloper\BetterDocs\Core\WriteWithAI::get_instructions()}.
271 + *
272 + * @return array<int,array{id:string,label:string,instruction:string,enabled:bool,predefined:bool}>
273 + */
274 + public static function get_actions() {
275 + $defaults = self::default_actions();
276 +
277 + $stored = betterdocs()->settings->get( 'ai_edit_actions', array() );
278 + $by_id = array();
279 + if ( is_array( $stored ) ) {
280 + foreach ( $stored as $item ) {
281 + if ( ! is_array( $item ) || empty( $item['id'] ) ) {
282 + continue;
283 + }
284 + $id = sanitize_key( (string) $item['id'] );
285 + $by_id[ $id ] = array(
286 + 'id' => $id,
287 + 'label' => isset( $item['label'] ) ? (string) $item['label'] : '',
288 + 'instruction' => isset( $item['instruction'] ) ? (string) $item['instruction'] : '',
289 + 'enabled' => isset( $item['enabled'] ) ? (bool) $item['enabled'] : true,
290 + 'predefined' => ! empty( $item['predefined'] ),
291 + );
292 + }
293 + }
294 +
295 + $actions = array();
296 + $seen = array();
297 +
298 + // Predefined actions first, in canonical order, with stored overrides applied.
299 + foreach ( $defaults as $default ) {
300 + $id = $default['id'];
301 + $seen[ $id ] = true;
302 + $override = isset( $by_id[ $id ] ) ? $by_id[ $id ] : array();
303 + $actions[] = array(
304 + 'id' => $id,
305 + 'label' => $default['label'], // label is locked for predefined.
306 + 'instruction' => isset( $override['instruction'] ) && '' !== trim( $override['instruction'] ) ? $override['instruction'] : $default['instruction'],
307 + 'enabled' => array_key_exists( 'enabled', $override ) ? $override['enabled'] : true,
308 + 'predefined' => true,
309 + );
310 + }
311 +
312 + // Custom actions (anything stored that isn't a predefined id), in stored order.
313 + foreach ( $by_id as $id => $item ) {
314 + if ( isset( $seen[ $id ] ) || $item['predefined'] ) {
315 + continue;
316 + }
317 + if ( '' === trim( $item['instruction'] ) ) {
318 + continue; // an action with no prompt would do nothing.
319 + }
320 + $actions[] = array(
321 + 'id' => $id,
322 + 'label' => '' !== trim( $item['label'] ) ? $item['label'] : $id,
323 + 'instruction' => $item['instruction'],
324 + 'enabled' => $item['enabled'],
325 + 'predefined' => false,
326 + );
327 + }
328 +
329 + return $actions;
330 + }
331 +
332 + /**
333 + * The enabled actions exposed to the editor modal, in its expected shape.
334 + *
335 + * @return array<int,array{id:string,label:string,prompt:string,needsOption:(string|false),structural:bool}>
336 + */
337 + public static function get_localized_actions() {
338 + $extras = self::predefined_extras();
339 + $actions = array();
340 +
341 + foreach ( self::get_actions() as $action ) {
342 + if ( empty( $action['enabled'] ) ) {
343 + continue;
344 + }
345 + $extra = isset( $extras[ $action['id'] ] ) ? $extras[ $action['id'] ] : array();
346 + $actions[] = array(
347 + 'id' => $action['id'],
348 + 'label' => $action['label'],
349 + 'prompt' => $action['instruction'],
350 + 'needsOption' => isset( $extra['needs_option'] ) ? $extra['needs_option'] : false,
351 + 'structural' => ! empty( $extra['structural'] ),
352 + );
353 + }
354 +
355 + return $actions;
356 + }
357 +
358 + /**
359 + * The enabled actions as prompt presets, keyed by id, for {@see self::generate()}.
360 + *
361 + * Derived from {@see self::get_actions()} so the stored settings are the single
362 + * source of truth; disabled actions are absent and therefore rejected by the route.
363 + *
364 + * @return array<string,array{prompt:string,needs_option?:string,format?:string}>
365 + */
366 + public static function get_presets() {
367 + $extras = self::predefined_extras();
368 + $presets = array();
369 +
370 + foreach ( self::get_actions() as $action ) {
371 + if ( empty( $action['enabled'] ) ) {
372 + continue;
373 + }
374 + $preset = array( 'prompt' => $action['instruction'] );
375 + if ( isset( $extras[ $action['id'] ] ) ) {
376 + $preset = array_merge( $preset, $extras[ $action['id'] ] );
377 + }
378 + $presets[ $action['id'] ] = $preset;
379 + }
380 +
381 + return $presets;
224 382 }
225 383 }