PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.8.1
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.8.1
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 3.5.2 All 199 releases
betterdocs / includes / REST / AIEdit.php

AIEdit.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.8.1, at includes/REST/AIEdit.php

384 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace WPDeveloper\BetterDocs\REST;
4
5 use WP_REST_Request;
6 use WPDeveloper\BetterDocs\Core\BaseAPI;
7 use WPDeveloper\BetterDocs\Utils\AIUsage;
8
9 class AIEdit extends BaseAPI {
10
11 const MAX_SELECTION_LENGTH = 12000;
12 const MAX_INSTRUCTION_LENGTH = 1000;
13
14 public function register() {
15 $this->post(
16 '/ai-edit',
17 array( $this, 'generate' ),
18 array(
19 'post_id' => array(
20 'type' => 'integer',
21 'required' => true
22 ),
23 'action' => array(
24 'type' => 'string',
25 'required' => true
26 ),
27 'selection' => array(
28 'type' => 'string',
29 'required' => false,
30 'default' => ''
31 ),
32 'selection_type' => array(
33 'type' => 'string',
34 'required' => false,
35 'default' => 'block'
36 ),
37 'instruction' => array(
38 'type' => 'string',
39 'required' => false,
40 'default' => ''
41 ),
42 'option' => array(
43 'type' => 'string',
44 'required' => false,
45 'default' => ''
46 ),
47 'instruction_ids' => array(
48 'type' => 'array',
49 'required' => false,
50 'default' => array()
51 )
52 )
53 );
54 }
55
56 public function permission_check() {
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' );
60 }
61
62 public function generate( WP_REST_Request $request ) {
63 $write_ai = betterdocs()->ai_autowrtie;
64
65 if ( empty( $write_ai ) || ! $write_ai->isEnabledWriteWithAI() ) {
66 return $this->error(
67 'ai_disabled',
68 __( 'Write with AI is disabled. Enable it from BetterDocs settings.', 'betterdocs' ),
69 400
70 );
71 }
72
73 $api_key = $write_ai->get_api_key();
74 if ( empty( $api_key ) ) {
75 return $this->error(
76 'ai_no_key',
77 __( 'AI API key is missing. Add one in BetterDocs settings.', 'betterdocs' ),
78 400
79 );
80 }
81
82 $action = sanitize_key( (string) $request->get_param( 'action' ) );
83 $selection_type = sanitize_key( (string) $request->get_param( 'selection_type' ) );
84 $selection = (string) $request->get_param( 'selection' );
85 $instruction = (string) $request->get_param( 'instruction' );
86 $option = sanitize_text_field( (string) $request->get_param( 'option' ) );
87
88 if ( strlen( $selection ) > self::MAX_SELECTION_LENGTH ) {
89 $selection = substr( $selection, 0, self::MAX_SELECTION_LENGTH );
90 }
91 if ( strlen( $instruction ) > self::MAX_INSTRUCTION_LENGTH ) {
92 $instruction = substr( $instruction, 0, self::MAX_INSTRUCTION_LENGTH );
93 }
94
95 $instruction = wp_kses_post( $instruction );
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
106 if ( 'inline' !== $selection_type ) {
107 $selection_type = 'block';
108 }
109
110 $presets = self::get_presets();
111
112 if ( 'custom' !== $action && ! isset( $presets[ $action ] ) ) {
113 return $this->error(
114 'ai_bad_action',
115 __( 'Unknown AI action.', 'betterdocs' ),
116 400
117 );
118 }
119
120 if ( trim( wp_strip_all_tags( $instruction ) ) === '' ) {
121 return $this->error(
122 'ai_empty_instruction',
123 __( 'Please provide a prompt for the AI.', 'betterdocs' ),
124 400
125 );
126 }
127
128 if ( trim( wp_strip_all_tags( $selection ) ) === '' && 'continue' !== $action ) {
129 return $this->error(
130 'ai_empty_selection',
131 __( 'No content selected for the AI to work on.', 'betterdocs' ),
132 400
133 );
134 }
135
136 $prompt = $this->build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option );
137
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' ) );
140
141 $result = $write_ai->generate_openai_response_ai_edit( $prompt, $extra_system );
142
143 if ( empty( $result[ 'success' ] ) ) {
144 $message = isset( $result[ 'error' ] ) ? (string) $result[ 'error' ] : __( 'Unknown AI error.', 'betterdocs' );
145 return $this->error( 'ai_upstream', $message, 502 );
146 }
147
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' ] ) );
152
153 if ( '' === $content ) {
154 return $this->error(
155 'ai_empty_response',
156 __( 'The AI returned no content. Try again or rephrase your instruction.', 'betterdocs' ),
157 502
158 );
159 }
160
161 AIUsage::record( 'ai_edit', (int) $request->get_param( 'post_id' ), $action );
162
163 return $this->success(
164 array(
165 'content' => $content,
166 'action' => $action,
167 'usage' => array(
168 'prompt_tokens' => isset( $result[ 'prompt_tokens' ] ) ? $result[ 'prompt_tokens' ] : null,
169 'completion_tokens' => isset( $result[ 'completion_tokens' ] ) ? $result[ 'completion_tokens' ] : null,
170 'total_tokens' => isset( $result[ 'total_tokens' ] ) ? $result[ 'total_tokens' ] : null
171 ),
172 'model' => isset( $result[ 'model' ] ) ? $result[ 'model' ] : null
173 )
174 );
175 }
176
177 protected function build_prompt( $action, $presets, $selection, $selection_type, $instruction, $option ) {
178 $core = trim( $instruction );
179
180 $preset = 'custom' !== $action && isset( $presets[ $action ] ) ? $presets[ $action ] : null;
181 $format_hint = isset( $preset[ 'format' ] ) && $preset[ 'format' ]
182 ? $preset[ 'format' ]
183 : ( 'inline' === $selection_type ? __( 'Return only plain text (no block markup, no quotes, no explanations).', 'betterdocs' )
184 : __( 'Return only valid Gutenberg-compatible HTML using standard tags such as <p>, <h2>, <ul>, <ol>, <li>, <strong>, <em>, <a>, <code>. Do not include explanations, preambles, code fences, or markdown.', 'betterdocs' ) );
185
186 $prompt = $core . "\n\n";
187 $prompt .= __( 'Formatting requirements:', 'betterdocs' ) . ' ' . $format_hint . "\n\n";
188 $prompt .= __( 'Content to work on:', 'betterdocs' ) . "\n";
189 $prompt .= "---\n" . $selection . "\n---";
190
191 return $prompt;
192 }
193
194 protected function clean_output( $content ) {
195 $content = trim( $content );
196
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 );
201 $content = preg_replace( '/\n?```\s*$/', '', $content );
202
203 return trim( $content );
204 }
205
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() {
254 return array(
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.',
260 ),
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;
382 }
383 }
384