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
← All changes | includes/Core/WriteWithAI.php +176 -3 4.8.1 → 4.9.4 View file →
@@ -16,8 +16,9 @@
16 16 use WPDeveloper\BetterDocs\AI\ProviderFactory;
17 17 use WPDeveloper\BetterDocs\AI\ModelRegistry;
18 18 use WPDeveloper\BetterDocs\Utils\AIUsage;
19 19 use WPDeveloper\BetterDocs\REST\AIEdit;
20 + use WPDeveloper\BetterDocs\REST\WriteWithAI as RESTWriteWithAI;
20 21
21 22 class WriteWithAI extends Base {
22 23
23 24 public $settings;
@@ -64,11 +65,11 @@
64 65 $platform_labels = ModelRegistry::platforms();
65 66 $model_labels = ModelRegistry::models( $active_platform );
66 67
67 68 // Glossary suggestions are existing-terms-only, and the glossaries taxonomy is only
68 - // registered for Pro (see Core\PostType::register_glossaries_taxonomy). So the "Suggest
69 - // glossaries" control must require, on top of the two settings, that Pro is active AND at
70 - // least one glossary term exists — otherwise the modal advertises an offer that can never
69 + // registered by Pro (see BetterDocsPro\Core\GlossaryTaxonomy::register_glossaries_taxonomy).
70 + // So the "Suggest glossaries" control must require, on top of the two settings, that Pro is
71 + // active AND at least one glossary term exists — otherwise the modal advertises an offer that can never
71 72 // return anything. Mirrors the Docs-AI-suite availability check in Core\DocsAISuite.
72 73 $glossary_count = wp_count_terms( array( 'taxonomy' => 'glossaries', 'hide_empty' => false ) );
73 74 $has_glossary_terms = ! is_wp_error( $glossary_count ) && (int) $glossary_count > 0;
74 75 $glossary_suggestions_enabled = (bool) $this->settings->get( 'enable_glossaries', false )
@@ -100,8 +101,15 @@
100 101 'platform_label' => isset( $platform_labels[ $active_platform ] ) ? $platform_labels[ $active_platform ] : ucfirst( (string) $active_platform ),
101 102 'model' => $active_model,
102 103 'model_label' => isset( $model_labels[ $active_model ] ) ? $model_labels[ $active_model ] : $active_model,
103 104 'max_token' => (int) $this->settings->get( 'ai_autowrite_max_token', 2500 ),
105 + // Attachment limits, resolved server-side so the modal can refuse
106 + // an oversize file before uploading it — and so it advertises the
107 + // host's real ceiling rather than ours when the host is smaller.
108 + 'max_upload_bytes' => RESTWriteWithAI::upload_cap( 'file' ),
109 + 'max_media_bytes' => RESTWriteWithAI::upload_cap( 'media' ),
110 + 'media_exts' => RESTWriteWithAI::media_exts(),
111 + 'supports_transcription' => $this->platform_supports( 'transcription', $active_platform ),
104 112 'settings_url' => esc_url( admin_url( 'admin.php?page=betterdocs-settings#betterdocs-ai' ) ),
105 113 'woo_active' => class_exists( 'WooCommerce' ),
106 114 'is_multilingual_active' => Helper::is_multilingual_active(),
107 115 'language_options' => Helper::get_active_languages(),
@@ -432,8 +440,173 @@
432 440 return $result['content'];
433 441 } catch ( \Exception $error ) {
434 442 return 'Error: ' . $error->getMessage();
435 443 }
444 + }
445 +
446 + /**
447 + * Generate documentation from an uploaded image using a vision-capable model.
448 + *
449 + * Mirrors generate_openai_response() but sends the picture alongside the text
450 + * prompt as an OpenAI-format multimodal user message
451 + * (`content: [ {type:text}, {type:image_url} ]`). The OpenAI-compatible
452 + * provider forwards that message array to the wire verbatim, so no provider
453 + * change is needed. Only OpenAI vision models are wired — Claude and Gemini
454 + * use a different image envelope, so they are refused with a clear error
455 + * instead of being sent a payload they would reject.
456 + *
457 + * @param string $prompt Composed instruction prompt.
458 + * @param array $image { data_uri:string, mime:string }.
459 + * @param int|null $max_tokens Optional token cap.
460 + * @param array $extra_system Extra system messages (instruction sets).
461 + * @return string|\WP_Error Generated content, or WP_Error on guard/failure.
462 + */
463 + public function generate_vision_response( $prompt, $image, $max_tokens = null, $extra_system = array() ) {
464 + if ( empty( $image['data_uri'] ) ) {
465 + return new \WP_Error( 'ai_vision_no_image', __( 'No image data to send to the AI.', 'betterdocs' ) );
466 + }
467 +
468 + $factory = new ProviderFactory( $this->settings );
469 + $platform = $factory->active_platform();
470 + $model = $factory->active_model( $platform );
471 +
472 + if ( ! $this->platform_supports( 'vision', $platform, $model ) ) {
473 + return new \WP_Error(
474 + 'ai_no_vision',
475 + sprintf(
476 + /* translators: 1: AI platform id, 2: model name. */
477 + __( 'The configured AI model (%1$s / %2$s) can\'t read images. Switch to an OpenAI vision model such as GPT-4o or GPT-4o mini in BetterDocs → Settings → AI Content Suite, or upload a PDF/DOCX/TXT instead.', 'betterdocs' ),
478 + $platform,
479 + '' !== (string) $model ? $model : 'default'
480 + )
481 + );
482 + }
483 +
484 + try {
485 + $messages = array_merge(
486 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
487 + $this->normalize_extra_system( $extra_system ),
488 + array(
489 + array(
490 + 'role' => 'user',
491 + 'content' => array(
492 + array( 'type' => 'text', 'text' => (string) $prompt ),
493 + array( 'type' => 'image_url', 'image_url' => array( 'url' => (string) $image['data_uri'] ) ),
494 + ),
495 + ),
496 + )
497 + );
498 +
499 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( $max_tokens ) );
500 +
501 + if ( is_wp_error( $result ) ) {
502 + return $result;
503 + }
504 +
505 + return $result['content'];
506 + } catch ( \Exception $error ) {
507 + return new \WP_Error( 'ai_vision_failed', 'Error: ' . $error->getMessage() );
508 + }
509 + }
510 +
511 + /**
512 + * Transcribe an uploaded recording to plain text.
513 + *
514 + * Deliberately not a doc-generation call: this returns the raw transcript so
515 + * the author can correct it, and generation then runs through the ordinary
516 + * grounded from-source path. That keeps one generation path in the plugin
517 + * instead of a second, media-shaped one.
518 + *
519 + * @since 4.9.4
520 + *
521 + * @param array $file `[ 'path', 'filename', 'mime' ]` — a PHP temp upload.
522 + * @return string|\WP_Error Transcript text.
523 + */
524 + public function transcribe( $file ) {
525 + $factory = new ProviderFactory( $this->settings );
526 + $platform = $factory->active_platform();
527 +
528 + if ( ! $this->platform_supports( 'transcription', $platform ) ) {
529 + return new \WP_Error(
530 + 'ai_no_transcription',
531 + sprintf(
532 + /* translators: %s: AI platform id, e.g. "claude". */
533 + __( 'The configured AI platform (%s) can\'t read audio or video. Switch to OpenAI or Google Gemini in BetterDocs → Settings → AI Content Suite, or upload a text file instead.', 'betterdocs' ),
534 + $platform
535 + )
536 + );
537 + }
538 +
539 + // 300s, matching every other AI call here: a 25 MB recording can take a
540 + // minute or more to come back, well past the 50s provider default.
541 + $options = $this->ai_chat_options();
542 + $options['model'] = ModelRegistry::transcription_model( $platform );
543 +
544 + try {
545 + return $factory->make()->transcribe( $file, $options );
546 + } catch ( \Exception $error ) {
547 + return new \WP_Error( 'ai_transcribe_failed', 'Error: ' . $error->getMessage() );
548 + }
549 + }
550 +
551 + /**
552 + * Whether a platform + model can handle a given input capability.
553 + *
554 + * One entry point for every "can this provider read that file?" question, so
555 + * a new capability is a case here rather than another bespoke check beside
556 + * the call site.
557 + *
558 + * @since 4.9.4
559 + *
560 + * @param string $capability `vision` | `transcription`
561 + * @param string $platform
562 + * @param string $model Chat model. Ignored for transcription, which uses
563 + * its own model (see ModelRegistry).
564 + * @return bool
565 + */
566 + public function platform_supports( $capability, $platform, $model = '' ) {
567 + switch ( $capability ) {
568 + case 'vision':
569 + return $this->platform_supports_vision( $platform, $model );
570 +
571 + case 'transcription':
572 + // Presence in the transcription map is the whole test — the chat
573 + // model is irrelevant, since transcription runs as its own call
574 + // with its own model before any doc is written.
575 + return '' !== ModelRegistry::transcription_model( $platform );
576 + }
577 +
578 + return false;
579 + }
580 +
581 + /**
582 + * Whether the active platform + model can accept image input in the OpenAI
583 + * multimodal format. Deliberately conservative: only OpenAI vision model
584 + * families qualify, because Claude and Gemini require a different image
585 + * envelope this path does not build. gpt-3.5 (text-only) is excluded.
586 + *
587 + * @param string $platform
588 + * @param string $model
589 + * @return bool
590 + */
591 + protected function platform_supports_vision( $platform, $model ) {
592 + if ( 'openai' !== $platform ) {
593 + return false;
594 + }
595 +
596 + $model = strtolower( (string) $model );
597 +
598 + if ( '' === $model || false !== strpos( $model, 'gpt-3.5' ) ) {
599 + return false;
600 + }
601 +
602 + foreach ( array( 'gpt-4o', 'gpt-4.1', 'gpt-4-turbo', 'gpt-4-vision', 'chatgpt-4o', 'gpt-5', 'o1', 'o3', 'o4' ) as $family ) {
603 + if ( false !== strpos( $model, $family ) ) {
604 + return true;
605 + }
606 + }
607 +
608 + return false;
436 609 }
437 610
438 611 public function get_outline_system_prompt() {
439 612 $prompt = <<<'PROMPT'