PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.4
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.4
4.9.4 4.9.3 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 All 202 releases
betterdocs / includes / Core / AIActions.php

AIActions.php in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.4, at includes/Core/AIActions.php

617 lines 20.8 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\Core;
4
5 if ( ! defined( 'ABSPATH' ) ) {
6 exit; // Exit if accessed directly
7 }
8
9 use WPDeveloper\BetterDocs\Utils\Views;
10
11 /**
12 * The AI Actions registry.
13 *
14 * Renders a split button beside the single-doc title: a primary "Copy page"
15 * half that puts the doc on the clipboard as Markdown, and a disclosure half
16 * that opens the rest of the actions — view the raw Markdown, or hand the doc to
17 * ChatGPT, Claude or Google AI Studio as context.
18 *
19 * Every entry lives in a filterable array, so a Pro feature, an MCP install
20 * command or a PDF download becomes one more element in `betterdocs_ai_actions`
21 * rather than a change to this class or to the frontend JavaScript.
22 *
23 * An action is:
24 *
25 * id string unique slug; becomes data-bd-action
26 * label string translated, escaped on output
27 * description string translated secondary line
28 * icon string key for AIActions::icon(), or a raw SVG string
29 * setting_key string Settings key gating the item. Optional — an empty key,
30 * or one with no registered default, means "always on".
31 * type string `copy` (a button run by JS) | `link` (a real anchor)
32 * handler string JS handler id; required when type is `copy`
33 * source string `md` | `page` — which URL substitutes into {URL}
34 * href_template string {PROMPT} {URL} {MD_URL} {PAGE_URL} {TITLE}
35 * target string `_blank` or empty
36 * primary bool exactly one action may own the split button's main half
37 * priority int sort order
38 *
39 * @since 4.8.0
40 */
41 class AIActions {
42 /**
43 * @var Settings
44 */
45 protected $settings;
46
47 /**
48 * @var Views
49 */
50 protected $views;
51
52 /**
53 * @var MarkdownRenderer
54 */
55 protected $renderer;
56
57 /**
58 * @var MarkdownEndpoint
59 */
60 protected $endpoint;
61
62 /**
63 * Keys in a caller's `$args` that configure *which* actions resolve and *how*,
64 * as opposed to view params the template reads.
65 *
66 * They are split out before the array reaches Views, because Views params are
67 * sticky across calls (see render()) and a key no template reads has no business
68 * persisting into the next instance on the page.
69 *
70 * @var string[]
71 */
72 protected static $resolve_keys = [ 'prompt_template', 'enabled_actions' ];
73
74 public function __construct( Settings $settings, Views $views, MarkdownRenderer $renderer, MarkdownEndpoint $endpoint ) {
75 $this->settings = $settings;
76 $this->views = $views;
77 $this->renderer = $renderer;
78 $this->endpoint = $endpoint;
79 }
80
81 /**
82 * Master switch for the whole feature.
83 *
84 * @return bool
85 */
86 public function is_enabled() {
87 /**
88 * Toggle the AI Actions button independently of the setting.
89 *
90 * @since 4.8.0
91 *
92 * @param bool $enabled
93 */
94 return (bool) apply_filters( 'betterdocs_ai_actions_enabled', (bool) $this->settings->get( 'enable_ai_actions' ) );
95 }
96
97 /**
98 * Default keys, so a third-party registry entry may omit any of them.
99 *
100 * @return array
101 */
102 public function defaults() {
103 return [
104 'id' => '',
105 'label' => '',
106 'description' => '',
107 'icon' => '',
108 'setting_key' => '',
109 'type' => 'link',
110 'handler' => '',
111 'source' => 'md',
112 'href_template' => '',
113 'target' => '_blank',
114 'primary' => false,
115 'priority' => 100,
116 // Menu grouping. The view draws a rule wherever this changes between
117 // two consecutive entries, so an added entry only has to say which side
118 // of the divider it belongs on. `page` acts on this doc, `llm` hands it
119 // to somebody else's assistant.
120 'group' => 'llm'
121 ];
122 }
123
124 /**
125 * The registry.
126 *
127 * URL formats mirror what Mintlify ships in production. Two are worth calling
128 * out because they look like mistakes and are not:
129 *
130 * - ChatGPT gets the plain permalink rather than the `.md` URL. Its browsing
131 * tool renders HTML perfectly well, and the `hints=search` parameter makes it
132 * fetch rather than guess.
133 * - "Gemini" points at Google AI Studio. gemini.google.com has no prompt
134 * prefill parameter — the browser extensions that claim otherwise exist
135 * precisely because it does not — while AI Studio supports `?prompt=`.
136 *
137 * @return array id => definition
138 */
139 public function registry() {
140 $ask = __( 'Ask questions about this page', 'betterdocs' );
141
142 $actions = [
143 'copy-page' => [
144 'id' => 'copy-page',
145 'label' => __( 'Copy page', 'betterdocs' ),
146 'description' => __( 'Copy this page as Markdown for LLMs', 'betterdocs' ),
147 'icon' => 'copy',
148 'setting_key' => 'ai_actions_copy_page',
149 'type' => 'copy',
150 'handler' => 'copy-markdown',
151 'source' => 'md',
152 'target' => '',
153 'primary' => true,
154 'priority' => 10,
155 'group' => 'page'
156 ],
157 'view-markdown' => [
158 'id' => 'view-markdown',
159 'label' => __( 'View as Markdown', 'betterdocs' ),
160 'description' => __( 'Open this page as plain Markdown', 'betterdocs' ),
161 'icon' => 'markdown',
162 'setting_key' => 'ai_actions_view_markdown',
163 'href_template' => '{MD_URL}',
164 'priority' => 20,
165 'group' => 'page'
166 ],
167 'open-chatgpt' => [
168 'id' => 'open-chatgpt',
169 'label' => __( 'Open in ChatGPT', 'betterdocs' ),
170 'description' => $ask,
171 'icon' => 'chatgpt',
172 'setting_key' => 'ai_actions_chatgpt',
173 'source' => 'page',
174 'href_template' => 'https://chatgpt.com/?hints=search&q={PROMPT}',
175 'priority' => 30
176 ],
177 'open-claude' => [
178 'id' => 'open-claude',
179 'label' => __( 'Open in Claude', 'betterdocs' ),
180 'description' => $ask,
181 'icon' => 'claude',
182 'setting_key' => 'ai_actions_claude',
183 'href_template' => 'https://claude.ai/new?q={PROMPT}',
184 'priority' => 40
185 ],
186 'open-aistudio' => [
187 'id' => 'open-aistudio',
188 'label' => __( 'Open in Google AI Studio', 'betterdocs' ),
189 'description' => __( 'Ask questions about this page (Google account required)', 'betterdocs' ),
190 'icon' => 'gemini',
191 'setting_key' => 'ai_actions_gemini',
192 'href_template' => 'https://aistudio.google.com/prompts/new_chat?prompt={PROMPT}',
193 'priority' => 50
194 ],
195 'open-perplexity' => [
196 'id' => 'open-perplexity',
197 'label' => __( 'Open in Perplexity', 'betterdocs' ),
198 'description' => $ask,
199 'icon' => 'perplexity',
200 'setting_key' => 'ai_actions_perplexity',
201 // /search?q= answers with a 301; /search/new?q= is the current form.
202 'href_template' => 'https://www.perplexity.ai/search/new?q={PROMPT}',
203 'priority' => 60
204 ],
205 'open-grok' => [
206 'id' => 'open-grok',
207 'label' => __( 'Open in Grok', 'betterdocs' ),
208 'description' => $ask,
209 'icon' => 'grok',
210 'setting_key' => 'ai_actions_grok',
211 'href_template' => 'https://grok.com/?q={PROMPT}',
212 'priority' => 70
213 ]
214 ];
215
216 /**
217 * Filter the AI Actions registry.
218 *
219 * Add an element to put a new item in the dropdown. A `link` action needs no
220 * JavaScript at all; a `copy` action needs a matching handler registered on
221 * `window.betterdocsAIActionHandlers`.
222 *
223 * @since 4.8.0
224 *
225 * @param array $actions id => definition
226 * @param Settings $settings
227 */
228 return (array) apply_filters( 'betterdocs_ai_actions', $actions, $this->settings );
229 }
230
231 /**
232 * Registry, reduced to what this doc should actually show, with every URL
233 * resolved.
234 *
235 * `$args` lets one caller — a block, a widget, a shortcode — configure its own
236 * instance without touching the global settings. Every key is optional and a
237 * blank one means "inherit", so an untouched control falls through to the
238 * Settings panel rather than overriding it with emptiness.
239 *
240 * @param \WP_Post|int|null $post
241 * @param array $args {
242 * @type string $prompt_template Overrides `ai_actions_prompt_template`.
243 * @type array $enabled_actions Action id => bool, overriding that action's
244 * `setting_key`.
245 * }
246 * @return array
247 */
248 public function resolve( $post, $args = [] ) {
249 $post = get_post( $post );
250
251 if ( ! $post instanceof \WP_Post || ! $this->renderer->can_read( $post ) ) {
252 return [];
253 }
254
255 $md_url = $this->endpoint->url( $post );
256 $page_url = get_permalink( $post );
257 $template = isset( $args['prompt_template'] ) ? (string) $args['prompt_template'] : '';
258
259 // Blank means inherit, at both levels: an untouched block control falls
260 // through to the setting, and a cleared setting falls through to the string
261 // the feature shipped with.
262 if ( '' === trim( $template ) ) {
263 $template = (string) $this->settings->get( 'ai_actions_prompt_template' );
264 }
265
266 if ( '' === trim( $template ) ) {
267 $template = __( 'Read from {URL} so I can ask questions about it.', 'betterdocs' );
268 }
269
270 $resolved = [];
271
272 foreach ( $this->registry() as $id => $action ) {
273 $action = wp_parse_args( $action, $this->defaults() );
274 $action['id'] = '' !== $action['id'] ? $action['id'] : $id;
275
276 if ( ! $this->action_enabled( $action, $args ) ) {
277 continue;
278 }
279
280 // An action that needs the Markdown address is meaningless when the
281 // endpoint is off or the doc has no Markdown URL.
282 if ( 'md' === $action['source'] && '' === $md_url ) {
283 continue;
284 }
285
286 $url = 'page' === $action['source'] ? $page_url : $md_url;
287 $prompt = str_replace( '{URL}', $url, $template );
288
289 /**
290 * Filter the prompt handed to an AI assistant.
291 *
292 * @since 4.8.0
293 *
294 * @param string $prompt
295 * @param array $action
296 * @param \WP_Post $post
297 */
298 $prompt = (string) apply_filters( 'betterdocs_ai_actions_prompt', $prompt, $action, $post );
299
300 $action['href'] = strtr(
301 $action['href_template'],
302 [
303 '{PROMPT}' => rawurlencode( $prompt ),
304 '{URL}' => $url,
305 '{MD_URL}' => $md_url,
306 '{PAGE_URL}' => $page_url,
307 '{TITLE}' => rawurlencode( get_the_title( $post ) )
308 ]
309 );
310
311 $resolved[ $action['id'] ] = $action;
312 }
313
314 uasort(
315 $resolved,
316 function ( $a, $b ) {
317 return (int) $a['priority'] - (int) $b['priority'];
318 }
319 );
320
321 /**
322 * Last chance to alter the rendered action list for a doc.
323 *
324 * @since 4.8.0
325 * @since 4.9.1 `$args` added.
326 *
327 * @param array $resolved
328 * @param \WP_Post $post
329 * @param array $args Per-instance overrides from the calling surface.
330 */
331 return (array) apply_filters( 'betterdocs_ai_actions_resolved', $resolved, $post, $args );
332 }
333
334 /**
335 * The registry, normalised and ordered, for a surface that has no doc to
336 * resolve against.
337 *
338 * The block editor is the caller: it has to draw the dropdown so the author can
339 * see what each toggle does, but there is no post to build hrefs from and the
340 * preview must never navigate anywhere. So this is everything the menu needs to
341 * be *drawn* — label, description, icon markup, whether it owns the primary half
342 * — and nothing it would need to be *used*.
343 *
344 * Order and `setting_key` come straight from registry(), so an entry a third
345 * party adds through `betterdocs_ai_actions` shows up in the editor too.
346 *
347 * @since 4.9.1
348 *
349 * @return array[] Ordered list, lowest priority first.
350 */
351 public function catalog() {
352 $catalog = [];
353
354 foreach ( $this->registry() as $id => $action ) {
355 $action = wp_parse_args( $action, $this->defaults() );
356
357 $catalog[] = [
358 'id' => '' !== $action['id'] ? $action['id'] : $id,
359 'label' => (string) $action['label'],
360 'description' => (string) $action['description'],
361 'icon' => self::icon( $action['icon'] ),
362 'setting_key' => (string) $action['setting_key'],
363 'primary' => (bool) $action['primary'],
364 // Only a link can open a tab. The editor no longer draws an arrow for
365 // it — neither does the frontend — but the flag still says which rows
366 // leave the site, and a third-party preview may want it.
367 'external' => 'copy' !== $action['type'] && '_blank' === $action['target'],
368 // So the editor preview can draw the same group rule as the frontend.
369 'group' => (string) $action['group'],
370 'priority' => (int) $action['priority']
371 ];
372 }
373
374 usort(
375 $catalog,
376 function ( $a, $b ) {
377 return $a['priority'] - $b['priority'];
378 }
379 );
380
381 return $catalog;
382 }
383
384 /**
385 * Is this action switched on?
386 *
387 * An action whose `setting_key` is empty, or is not a key BetterDocs registers a
388 * default for, is treated as always on. That is what makes a third-party
389 * registry entry work without also registering a setting.
390 *
391 * Settings::get() is deliberately called with no `$default` argument: passing
392 * one overrides the registered default, and `betterdocs_settings` only ever
393 * contains keys the user has actually saved.
394 *
395 * A caller's `enabled_actions` map wins over both, and is keyed on the action id
396 * rather than on `setting_key` so that a third-party registry entry with no
397 * setting at all is still switchable per instance.
398 *
399 * @param array $action
400 * @param array $args
401 * @return bool
402 */
403 protected function action_enabled( $action, $args = [] ) {
404 if ( isset( $args['enabled_actions'][ $action['id'] ] ) ) {
405 return (bool) $args['enabled_actions'][ $action['id'] ];
406 }
407
408 $key = $action['setting_key'];
409
410 if ( '' === $key ) {
411 return true;
412 }
413
414 $registered = array_merge( $this->settings->get_default(), $this->settings->get_pro_defaults() );
415 if ( ! array_key_exists( $key, $registered ) ) {
416 return true;
417 }
418
419 return (bool) $this->settings->get( $key );
420 }
421
422 /**
423 * Will render() actually output anything for this doc?
424 *
425 * Templates need this before they commit to a layout: every action can be
426 * switched off individually, and a doc the reader may not read resolves to
427 * nothing at all, so "the feature is on" is not the same as "there is a button".
428 *
429 * @param \WP_Post|int|null $post
430 * @param array $args Per-instance overrides, as for resolve(). An
431 * `enable` of false answers false outright.
432 * @return bool
433 */
434 public function has_actions( $post = null, $args = [] ) {
435 if ( isset( $args['enable'] ) && ! $args['enable'] ) {
436 return false;
437 }
438
439 if ( ! $this->is_enabled() ) {
440 return false;
441 }
442
443 $post = get_post( $post );
444
445 return $post instanceof \WP_Post && ! empty( $this->resolve( $post, $args ) );
446 }
447
448 /**
449 * Render the button for a doc.
450 *
451 * @param \WP_Post|int|null $post
452 * @param array $extra {
453 * View params, plus the per-instance overrides resolve() understands.
454 *
455 * @type bool $enable Switch this instance off. Cannot switch it
456 * *on* — see below.
457 * @type string $button_label Overrides `ai_actions_button_label`.
458 * @type string $prompt_template Overrides `ai_actions_prompt_template`.
459 * @type array $enabled_actions Action id => bool.
460 * @type string $widget_type
461 * @type string $blockId
462 * }
463 */
464 public function render( $post = null, $extra = [] ) {
465 $post = get_post( $post );
466
467 if ( ! $post instanceof \WP_Post ) {
468 return;
469 }
470
471 // A surface may switch its own instance off, but never on: the global stays a
472 // hard kill switch, so turning the feature off in Settings cannot be undone
473 // by a block somebody placed a year ago and forgot about.
474 if ( isset( $extra['enable'] ) && ! $extra['enable'] ) {
475 return;
476 }
477
478 if ( ! $this->is_enabled() ) {
479 return;
480 }
481
482 $actions = $this->resolve( $post, $extra );
483 if ( empty( $actions ) ) {
484 return;
485 }
486
487 // Views is a shared singleton whose params are sticky across calls, so every
488 // key this template reads has to be passed explicitly — otherwise a block
489 // rendered earlier on the page leaks its blockId into the classic instance.
490 // For the same reason the resolve-only keys are dropped here rather than
491 // forwarded: no template reads them, so nothing should keep them alive.
492 $this->views->get(
493 'templates/parts/ai-actions',
494 wp_parse_args(
495 array_diff_key( $extra, array_flip( self::$resolve_keys ) ),
496 [
497 'enable' => true,
498 'actions' => $actions,
499 'md_url' => $this->endpoint->url( $post ),
500 // The clipboard gets the human-facing profile — no YAML front
501 // matter, since this is about to be pasted into a chat box.
502 'copy_url' => $this->endpoint->url( $post, 'copy' ),
503 'page_url' => get_permalink( $post ),
504 'doc_id' => (int) $post->ID,
505 'uid' => wp_unique_id( 'betterdocs-ai-actions-' ),
506 'button_label' => (string) $this->settings->get( 'ai_actions_button_label' ),
507 'widget_type' => '',
508 'blockId' => ''
509 ]
510 )
511 );
512 }
513
514 /**
515 * Inline SVG for an icon key. An unrecognised key that looks like markup is
516 * returned as-is (so a registry entry can supply its own), otherwise the generic
517 * sparkle is used.
518 *
519 * @param string $key
520 * @return string
521 */
522 public static function icon( $key ) {
523 if ( false !== strpos( (string) $key, '<svg' ) ) {
524 return $key;
525 }
526
527 // Weights, not sizes: the stylesheet sizes these (14px in the button, 15px
528 // in the menu tiles), and at 14px a 1.8 stroke leaves the two smallest
529 // glyphs — the caret and the copied tick — visibly lighter than the label
530 // beside them. Both are drawn at 2.4, which is what the design specifies.
531 $strokes = [
532 'caret' => '2.4',
533 'check' => '2.4'
534 ];
535 $stroke = isset( $strokes[ $key ] ) ? $strokes[ $key ] : '2';
536
537 $open = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="' . $stroke . '" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">';
538 $shapes = [
539 'copy' => '<rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
540 'check' => '<path d="m20 6-11 11-5-5"/>',
541 'alert' => '<circle cx="12" cy="12" r="9"/><path d="M12 8v5M12 16.5h.01"/>',
542 'markdown' => '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M6 15V9l3 3 3-3v6M17 9v5m0 0 2-2m-2 2-2-2"/>',
543 'caret' => '<path d="m6 9 6 6 6-6"/>',
544 'chatgpt' => '<path d="M12 2.5 20.5 7.25v9.5L12 21.5 3.5 16.75v-9.5z"/><path d="M12 7.5v9M8.2 9.6l7.6 4.4M15.8 9.6l-7.6 4.4"/>',
545 // Anthropic's mark is a radiating burst.
546 'claude' => '<path d="M12 2.5v19M2.5 12h19M5.2 5.2l13.6 13.6M18.8 5.2 5.2 18.8"/>',
547 // Google's Gemini spark: a four-pointed concave star.
548 'gemini' => '<path d="M12 2c0 5.52 4.48 10 10 10-5.52 0-10 4.48-10 10 0-5.52-4.48-10-10-10 5.52 0 10-4.48 10-10Z"/>',
549 'perplexity' => '<path d="M12 3.5v17M12 9 5.5 4v6.5h13V4L12 9M5.5 13.5V20l6.5-5 6.5 5v-6.5"/>',
550 'grok' => '<path d="M4.5 19.5 19.5 4.5M9.5 4.5 19.5 19.5M4.5 4.5 9 10.5"/>',
551 'sparkle' => '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M5.6 5.6l2.8 2.8M15.6 15.6l2.8 2.8M18.4 5.6l-2.8 2.8M8.4 15.6l-2.8 2.8"/>'
552 ];
553
554 $shape = isset( $shapes[ $key ] ) ? $shapes[ $key ] : $shapes['sparkle'];
555
556 return $open . $shape . '</svg>';
557 }
558
559 /**
560 * wp_kses allowlist for action icons, including SVGs supplied by third-party
561 * registry entries.
562 *
563 * Attribute names are lower-cased by wp_kses, so `viewBox` is listed as
564 * `viewbox` — which is what HTML parsing expects anyway.
565 *
566 * @return array
567 */
568 public static function svg_kses() {
569 $shape = [
570 'fill' => [],
571 'fill-rule' => [],
572 'fill-opacity' => [],
573 'clip-rule' => [],
574 'clip-path' => [],
575 'stroke' => [],
576 'stroke-width' => [],
577 'stroke-linecap' => [],
578 'stroke-linejoin' => [],
579 'stroke-dasharray' => [],
580 'opacity' => [],
581 'transform' => [],
582 'class' => [],
583 'style' => [],
584 'mask' => [],
585 'id' => []
586 ];
587
588 return [
589 'svg' => array_merge(
590 $shape,
591 [
592 'xmlns' => [],
593 'viewbox' => [],
594 'width' => [],
595 'height' => [],
596 'aria-hidden' => [],
597 'role' => [],
598 'focusable' => []
599 ]
600 ),
601 'g' => $shape,
602 'path' => array_merge( $shape, [ 'd' => [] ] ),
603 'rect' => array_merge( $shape, [ 'x' => [], 'y' => [], 'width' => [], 'height' => [], 'rx' => [], 'ry' => [] ] ),
604 'circle' => array_merge( $shape, [ 'cx' => [], 'cy' => [], 'r' => [] ] ),
605 'ellipse' => array_merge( $shape, [ 'cx' => [], 'cy' => [], 'rx' => [], 'ry' => [] ] ),
606 'line' => array_merge( $shape, [ 'x1' => [], 'y1' => [], 'x2' => [], 'y2' => [] ] ),
607 'polyline' => array_merge( $shape, [ 'points' => [] ] ),
608 'polygon' => array_merge( $shape, [ 'points' => [] ] ),
609 'mask' => array_merge( $shape, [ 'maskunits' => [], 'x' => [], 'y' => [], 'width' => [], 'height' => [] ] ),
610 'defs' => [],
611 'clippath' => [ 'id' => [] ],
612 'title' => [],
613 'desc' => []
614 ];
615 }
616 }
617