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 +717 -979 4.4.1 → 4.9.4 View file →
@@ -1,13 +1,24 @@
1 1 <?php
2 2
3 3 namespace WPDeveloper\BetterDocs\Core;
4 4
5 +if ( ! defined( 'ABSPATH' ) ) {
6 + exit;
7 +}
8 +
9 +
5 10 use WPDeveloper\BetterDocs\Utils\Base;
6 11 use WPDeveloper\BetterDocs\Core\Settings;
7 12 use WPDeveloper\BetterDocs\Core\PostType;
8 13
9 14 use WPDeveloper\BetterDocs\Utils\Helper;
15 + use WPDeveloper\BetterDocs\Utils\AIHelper;
16 + use WPDeveloper\BetterDocs\AI\ProviderFactory;
17 + use WPDeveloper\BetterDocs\AI\ModelRegistry;
18 + use WPDeveloper\BetterDocs\Utils\AIUsage;
19 + use WPDeveloper\BetterDocs\REST\AIEdit;
20 + use WPDeveloper\BetterDocs\REST\WriteWithAI as RESTWriteWithAI;
10 21
11 22 class WriteWithAI extends Base {
12 23
13 24 public $settings;
@@ -13,1103 +24,830 @@
13 24 public $settings;
14 25
15 26 public function __construct( Settings $settings ) {
16 27 $this->settings = $settings;
28 + // Get the post ID from the URL
29 + $post_id = isset( $_GET[ 'post' ] ) ? intval( $_GET[ 'post' ] ) : 0; // phpcs:ignore
17 30
18 - if ( ! empty( $this->isEnabledWriteWithAI() ) ) {
19 - add_action( 'current_screen', array( $this, 'maybe_register_ai_autowrite_button' ) );
31 + if ( ! empty( $_GET[ 'post_type' ] ) ) { // phpcs:ignore
32 + $post_type = $_GET[ 'post_type' ]; // phpcs:ignore
33 + } elseif ( $post_id > 0 ) {
34 + $post_type = get_post_type( $post_id );
35 + } else {
36 + $post_type = '';
20 37 }
38 +
39 + if ( ! empty( $this->isEnabledWriteWithAI() ) && 'docs' == $post_type ) {
40 + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_ai_edit_assets' ) );
41 + }
42 + // Legacy AJAX handler kept for back-compat; the redesigned modal uses the REST route.
21 43 add_action( 'wp_ajax_generate_openai_content', array( $this, 'generate_openai_content_callback' ) );
22 44 }
23 45
24 - public function maybe_register_ai_autowrite_button() {
25 - if ( ! current_user_can( 'edit_posts' ) ) {
46 + public function enqueue_ai_edit_assets( $hook ) {
47 + if ( 'post.php' !== $hook && 'post-new.php' !== $hook ) {
26 48 return;
27 49 }
28 50
29 - $screen = get_current_screen();
30 - if ( $screen && 'docs' === $screen->post_type && 'post' === $screen->base ) {
31 - add_action( 'admin_footer', array( $this, 'ai_autowrite_button' ) );
51 + global $post_type;
52 + if ( 'docs' !== $post_type ) {
53 + return;
32 54 }
33 - }
34 55
35 - public function isEnabledWriteWithAI() {
36 - $isEnableAutoWrite = $this->settings->get( 'enable_write_with_ai', true );
37 - return $isEnableAutoWrite;
38 - }
56 + $factory = new ProviderFactory( $this->settings );
57 + $api_key = $factory->api_key_for( $factory->active_platform() );
58 + $has_key = ! empty( $api_key );
39 59
40 - public function isValidAPIKey( $apiKey ) {
41 - if ( empty( $apiKey ) ) {
42 - $api_response[ 'valid' ] = false;
43 - $api_response[ 'message' ] = 'Please Insert your <a href="/admin.php?page=betterdocs-settings">OpenAI API Key</a> to use this Write with AI feature.';
60 + // Resolve the *active* AI platform + model (multi-platform aware) so the
61 + // modal reflects the current Settings → AI selection instead of the legacy
62 + // OpenAI-only `write_with_ai_model` key.
63 + $active_platform = $factory->active_platform();
64 + $active_model = $factory->active_model();
65 + $platform_labels = ModelRegistry::platforms();
66 + $model_labels = ModelRegistry::models( $active_platform );
44 67
45 - return $api_response;
46 - }
68 + // Glossary suggestions are existing-terms-only, and the glossaries taxonomy is only
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
72 + // return anything. Mirrors the Docs-AI-suite availability check in Core\DocsAISuite.
73 + $glossary_count = wp_count_terms( array( 'taxonomy' => 'glossaries', 'hide_empty' => false ) );
74 + $has_glossary_terms = ! is_wp_error( $glossary_count ) && (int) $glossary_count > 0;
75 + $glossary_suggestions_enabled = (bool) $this->settings->get( 'enable_glossaries', false )
76 + && (bool) $this->settings->get( 'show_glossary_suggestions', true )
77 + && betterdocs()->is_pro_active()
78 + && $has_glossary_terms;
47 79
48 - $ch = curl_init( 'https://api.openai.com/v1/engines' ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_init
49 - curl_setopt( $ch, CURLOPT_RETURNTRANSFER, true ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
50 - curl_setopt(
51 - $ch,
52 - CURLOPT_HTTPHEADER,
53 - array( //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt
54 - 'Content-Type: application/json',
55 - 'Authorization: Bearer ' . $apiKey
80 + // Write with AI — loads even without a key so the modal can show its
81 + // "add an API key" banner (matches the legacy inline form behavior).
82 + betterdocs()->assets->enqueue( 'betterdocs-write-with-ai', 'blocks/write-with-ai.js' );
83 + betterdocs()->assets->enqueue( 'betterdocs-write-with-ai-style', 'blocks/write-with-ai-style.css' );
84 +
85 + wp_localize_script(
86 + 'betterdocs-write-with-ai',
87 + 'betterdocsWriteWithAI',
88 + array(
89 + 'rest_url' => esc_url_raw( rest_url( 'betterdocs/v1/write-with-ai' ) ),
90 + 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
91 + 'post_id' => get_the_ID(),
92 + 'has_key' => $has_key,
93 + // Term-suggestion (Docs AI Suite) wiring reused by the Write-with-AI
94 + // preview step. Endpoint + gate mirror REST\DocsAISuite / Core\DocsAISuite.
95 + 'rest_suggest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-suggest-terms' ) ),
96 + 'suggest_terms_enabled' => (bool) $this->settings->get( 'enable_docs_ai_suite', true ),
97 + // Glossary suggestions follow the glossary feature AND real availability
98 + // (Pro active + at least one glossary term); see the computation above.
99 + 'glossary_suggestions_enabled' => $glossary_suggestions_enabled,
100 + 'platform' => $active_platform,
101 + 'platform_label' => isset( $platform_labels[ $active_platform ] ) ? $platform_labels[ $active_platform ] : ucfirst( (string) $active_platform ),
102 + 'model' => $active_model,
103 + 'model_label' => isset( $model_labels[ $active_model ] ) ? $model_labels[ $active_model ] : $active_model,
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 ),
112 + 'settings_url' => esc_url( admin_url( 'admin.php?page=betterdocs-settings#betterdocs-ai' ) ),
113 + 'woo_active' => class_exists( 'WooCommerce' ),
114 + 'is_multilingual_active' => Helper::is_multilingual_active(),
115 + 'language_options' => Helper::get_active_languages(),
116 + 'instructions' => $this->get_instruction_choices(),
117 + // From Git is a Pro feature. Pro is detected here; Git enabled/auth
118 + // status is filled in by Pro via the filter below (default: off).
119 + 'is_pro_active' => betterdocs()->is_pro_active(),
120 + 'git' => apply_filters(
121 + 'betterdocs_write_with_ai_git',
122 + array(
123 + 'enabled' => false,
124 + 'connected' => false,
125 + 'settings_url' => admin_url( 'admin.php?page=betterdocs-settings#git-sync' ),
126 + )
127 + ),
56 128 )
57 129 );
58 130
59 - $api_response = array();
131 + // AI Edit — only meaningful with a key configured.
132 + if ( ! $has_key ) {
133 + return;
134 + }
60 135
61 - $response = curl_exec( $ch ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_exec
62 - $httpCode = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_getinfo
136 + betterdocs()->assets->enqueue( 'betterdocs-ai-edit', 'blocks/ai-edit.js' );
137 + betterdocs()->assets->enqueue( 'betterdocs-ai-edit-style', 'blocks/ai-edit-style.css' );
63 138
64 - curl_close( $ch ); //phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_close
139 + wp_localize_script(
140 + 'betterdocs-ai-edit',
141 + 'betterdocsAIEdit',
142 + array(
143 + 'rest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-edit' ) ),
144 + 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
145 + 'post_id' => get_the_ID(),
146 + 'instructions' => $this->get_instruction_choices(),
147 + 'actions' => AIEdit::get_localized_actions()
148 + )
149 + );
150 + }
65 151
66 - if ( 200 == $httpCode ) {
67 - $api_response[ 'valid' ] = true;
68 - $api_response[ 'message' ] = 'Valid API Key';
69 - } else {
70 - $responseData = json_decode( $response, true );
71 - // Access the message data (replace 'data' with the actual key used in the response)
72 - $messageData = $responseData[ 'error' ] ? $responseData[ 'error' ] : '';
73 - $api_response[ 'valid' ] = false;
74 - $api_response[ 'message' ] = $messageData[ 'message' ] ? $messageData[ 'message' ] : 'Invalid API Key';
152 + public function isEnabledWriteWithAI() {
153 + $isEnableAutoWrite = $this->settings->get( 'enable_write_with_ai', true );
154 + return $isEnableAutoWrite;
155 + }
156 +
157 + public function isValidAPIKey( $apiKey ) {
158 + if ( empty( $apiKey ) ) {
159 + return array(
160 + 'valid' => false,
161 + 'message' => 'Please Insert your <a href="/admin.php?page=betterdocs-settings#betterdocs-ai">API Key</a> to use this Write with AI feature.'
162 + );
75 163 }
76 164
77 - // print_r($response);
78 -
79 - return $api_response;
165 + $factory = new ProviderFactory( $this->settings );
166 + return $factory->validate( $factory->active_platform(), $apiKey );
80 167 }
81 168
82 169 public function get_api_key() {
83 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
84 - return $api_key;
170 + $factory = new ProviderFactory( $this->settings );
171 + return $factory->api_key_for( $factory->active_platform() );
85 172 }
86 173
87 - public function generate_openai_response( $prompt, $keywords ) {
88 - try {
89 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
90 - $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 1500 );
91 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
174 + /**
175 + * The built-in "Default" instruction body.
176 + *
177 + * Single source of truth shared by {@see Settings::get_default()} (which seeds
178 + * the editable "Default" instruction set) and {@see self::get_system_prompt()}
179 + * (the fallback when no saved Default content exists).
180 + *
181 + * @return string
182 + */
183 + public static function default_instruction_content() {
184 + return <<<'PROMPT'
185 +You are a Senior Technical Writer specializing in comprehensive, high-quality documentation. Your goal is to produce documentation that scores 100/100 on clarity, completeness, and structure.
92 186
93 - $api_endpoint = 'https://api.openai.com/v1/chat/completions'; // Update the endpoint based on OpenAI API version
187 +## Output format
94 188
95 - $request_options = array(
96 - 'headers' => array(
97 - 'Content-Type' => 'application/json',
98 - 'Authorization' => 'Bearer ' . $api_key
99 - ),
100 - 'body' => json_encode(
101 - array(
102 - 'model' => $model,
103 - 'messages' => array(
104 - array(
105 - 'role' => 'system',
106 - 'content' => 'You are a Senior Technical Writer specializing in comprehensive, high-quality documentation. Your goal is to write documentation that scores 100/100 on clarity, completeness, and structure.'
107 - ),
108 - array(
109 - 'role' => 'user',
110 - 'content' => $prompt
111 - )
112 - ),
113 - 'max_tokens' => $max_tokens
114 - )
115 - ),
116 - 'timeout' => 50
117 - );
189 +Return only HTML body content. Never wrap output in `<!doctype>`, `<html>`, `<head>`, `<body>`, or markdown code fences (no ```html ... ```).
118 190
119 - $response = wp_remote_post( $api_endpoint, $request_options );
191 +Use semantic, Gutenberg-friendly tags only:
192 +- Headings: `<h2>`, `<h3>`, `<h4>` (do not emit `<h1>` — it is reserved for the document title)
193 +- Paragraphs: `<p>` for prose
194 +- Lists: `<ul>`/`<ol>` with `<li>` for any list of items, steps, or bullet points — never fake a list with paragraphs or `<br>`
195 +- Links: `<a href="https://...">link text</a>` with absolute URLs
196 +- Inline emphasis: `<strong>`, `<em>`, `<code>`
197 +- Code blocks: `<pre><code>...</code></pre>` for multi-line code or commands
198 +- Quotes: `<blockquote>`
199 +- Tables: `<table>` with `<thead>`, `<tbody>`, `<tr>`, `<th>`, `<td>`
200 +- Images: `<img src="..." alt="...">`
120 201
121 - if ( is_wp_error( $response ) ) {
122 - return 'Error: ' . $response->get_error_message();
123 - } else {
124 - $body = wp_remote_retrieve_body( $response );
202 +Apply `<span class="highlight">key term</span>` to important topic terms inside headings and to the first occurrence of each keyword in body text. Use it sparingly — never wrap whole sentences or wrap text inside `href`, `src`, `alt`, or other attributes.
125 203
126 - $data = json_decode( $body, true );
204 +Do not emit `<style>`, `<script>`, `<iframe>`, `<form>`, or inline `style=""` / `class=""` attributes (other than the `highlight` class above).
127 205
128 - if ( ! empty( $data[ 'error' ] ) ) {
129 - return $data[ 'error' ][ 'message' ];
206 +If — and only if — the user's prompt explicitly asks for raw/custom HTML, embed code, or a non-standard structure, follow that request instead of these defaults.
207 +PROMPT;
208 + }
209 +
210 + /**
211 + * The saved instruction sets, normalized to a list of { id, title, content }.
212 + *
213 + * Stored in the `write_with_ai_instructions` setting. Always returns at least
214 + * the built-in "Default" set so callers never have to special-case an empty
215 + * option (e.g. a site whose stored value predates this feature).
216 + *
217 + * @return array<int,array{id:string,title:string,content:string}>
218 + */
219 + public function get_instructions() {
220 + $stored = $this->settings->get( 'write_with_ai_instructions', array() );
221 +
222 + $instructions = array();
223 + if ( is_array( $stored ) ) {
224 + foreach ( $stored as $item ) {
225 + if ( ! is_array( $item ) || empty( $item['id'] ) ) {
226 + continue;
130 227 }
228 + $instructions[] = array(
229 + 'id' => sanitize_key( (string) $item['id'] ),
230 + 'title' => isset( $item['title'] ) ? (string) $item['title'] : '',
231 + 'content' => isset( $item['content'] ) ? (string) $item['content'] : '',
232 + );
233 + }
234 + }
131 235
132 - return $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ]; // Update this line to get the assistant's message
236 + // Guarantee a Default set is always present.
237 + $has_default = false;
238 + foreach ( $instructions as $item ) {
239 + if ( 'default' === $item['id'] ) {
240 + $has_default = true;
241 + break;
133 242 }
134 - } catch ( Exception $error ) {
135 - return 'Error: ' . $error->getMessage();
136 243 }
244 + if ( ! $has_default ) {
245 + array_unshift(
246 + $instructions,
247 + array(
248 + 'id' => 'default',
249 + 'title' => __( 'Default/Core', 'betterdocs' ),
250 + 'content' => self::default_instruction_content(),
251 + )
252 + );
253 + }
254 +
255 + return $instructions;
137 256 }
138 257
139 - public function generate_openai_content_callback() {
140 - if ( ! current_user_can( 'edit_posts' ) ) {
141 - wp_send_json_error( 'Unauthorized' );
142 - wp_die();
258 + /**
259 + * The selectable (non-default) instruction sets exposed to the editor UI.
260 + *
261 + * Only id + title are sent to the browser; the prompt body stays server-side
262 + * and is resolved at request time by {@see self::get_instruction_messages()}.
263 + *
264 + * @return array<int,array{id:string,title:string}>
265 + */
266 + public function get_instruction_choices() {
267 + $choices = array();
268 + foreach ( $this->get_instructions() as $item ) {
269 + if ( 'default' === $item['id'] ) {
270 + continue;
271 + }
272 + if ( '' === trim( $item['content'] ) ) {
273 + continue; // skip empty sets — they'd inject nothing.
274 + }
275 + $choices[] = array(
276 + 'id' => $item['id'],
277 + 'title' => '' !== trim( $item['title'] ) ? $item['title'] : $item['id'],
278 + );
143 279 }
280 + return $choices;
281 + }
144 282
145 - // Verify the nonce
146 - if ( ! isset( $_POST[ 'ai_nonce' ] ) || ! wp_verify_nonce( $_POST[ 'ai_nonce' ], 'generate_openai_content_nonce' ) ) { //phpcs:ignore
147 - wp_send_json_error( 'Invalid nonce' );
148 - wp_die();
283 + /**
284 + * Resolve selected instruction ids into extra `system` messages.
285 + *
286 + * The "Default" set is intentionally excluded — it is always sent as the base
287 + * system prompt by {@see self::get_system_prompt()}. Unknown or empty ids are
288 + * silently ignored. Output order follows the requested $ids order.
289 + *
290 + * @param array $ids
291 + * @return array<int,array{role:string,content:string}>
292 + */
293 + public function get_instruction_messages( $ids ) {
294 + if ( empty( $ids ) || ! is_array( $ids ) ) {
295 + return array();
149 296 }
150 297
151 - $prompt = sanitize_text_field( $_POST[ 'prompt' ] ); //phpcs:ignore
152 - // $num_of_sections = sanitize_text_field($_POST['numOfSections']);
153 - // $num_of_paragraphs = sanitize_text_field($_POST['numOfParagraphs']);
154 - $keywords = sanitize_text_field( $_POST[ 'keywords' ] ); //phpcs:ignore
298 + $by_id = array();
299 + foreach ( $this->get_instructions() as $item ) {
300 + $by_id[ $item['id'] ] = $item;
301 + }
155 302
156 - $ai_instance = new WriteWithAI( $this->settings );
303 + $messages = array();
304 + $seen = array();
305 + foreach ( $ids as $id ) {
306 + $id = sanitize_key( (string) $id );
307 + if ( 'default' === $id || isset( $seen[ $id ] ) || ! isset( $by_id[ $id ] ) ) {
308 + continue;
309 + }
310 + $content = trim( $by_id[ $id ]['content'] );
311 + if ( '' === $content ) {
312 + continue;
313 + }
314 + $seen[ $id ] = true;
315 + $messages[] = array(
316 + 'role' => 'system',
317 + 'content' => $content,
318 + );
319 + }
157 320
158 - $generated_content = $ai_instance->generate_openai_response( $prompt, $keywords );
159 -
160 - // Send the generated content as the AJAX response
161 - wp_send_json_success( $generated_content );
162 - wp_die();
321 + return $messages;
163 322 }
164 323
165 - public function ai_autowrite_button() {
324 + /**
325 + * Coerce a list of extra messages into well-formed `system` chat messages.
326 + *
327 + * Accepts either pre-built [ 'role'=>'system', 'content'=>… ] entries (as
328 + * returned by {@see self::get_instruction_messages()}) or bare strings, and
329 + * drops anything empty. Keeps the OpenAI payload builders tolerant of either
330 + * shape so callers can pass instruction ids or ready-made messages.
331 + *
332 + * @param array $extra_system
333 + * @return array<int,array{role:string,content:string}>
334 + */
335 + protected function normalize_extra_system( $extra_system ) {
336 + if ( empty( $extra_system ) || ! is_array( $extra_system ) ) {
337 + return array();
338 + }
166 339
167 - ?>
168 - <script>
169 - var docsTitle;
340 + $messages = array();
341 + foreach ( $extra_system as $entry ) {
342 + if ( is_string( $entry ) ) {
343 + $content = trim( $entry );
344 + $role = 'system';
345 + } elseif ( is_array( $entry ) && isset( $entry['content'] ) ) {
346 + $content = trim( (string) $entry['content'] );
347 + $role = isset( $entry['role'] ) ? (string) $entry['role'] : 'system';
348 + } else {
349 + continue;
350 + }
170 351
171 - function responseMessage(message) {
172 - return `<div class="warning-message">
173 - <span class="dashicons dashicons-warning"></span>
174 - <div class="footer-message">
175 - ${message}
176 - </div>
177 - </div>`;
178 - }
352 + if ( '' === $content ) {
353 + continue;
354 + }
179 355
180 - function generateArticle(e) {
356 + $messages[] = array(
357 + 'role' => $role,
358 + 'content' => $content,
359 + );
360 + }
181 361
362 + return $messages;
363 + }
182 364
183 - const title = document.getElementById('betterdocs-ai-title').value;
184 - const keywords = document.getElementById('betterdocs-ai-keyword').value;
185 - // const sectionOptions = document.getElementById('bd-autowrtite-content-section');
186 - // const numOfSections = sectionOptions.options[sectionOptions.selectedIndex].value;
365 + public function get_system_prompt() {
366 + // Prefer the saved (editable) "Default" instruction set; fall back to the
367 + // built-in body when it's missing or has been cleared.
368 + $prompt = self::default_instruction_content();
369 + foreach ( $this->get_instructions() as $item ) {
370 + if ( 'default' === $item['id'] && '' !== trim( $item['content'] ) ) {
371 + $prompt = $item['content'];
372 + break;
373 + }
374 + }
187 375
188 - // const paragraphOptions = document.getElementById('bd-autowrtite-content-paragraph');
189 - // const numOfParagraphs = paragraphOptions.options[paragraphOptions.selectedIndex].value;
376 + return apply_filters( 'betterdocs_write_with_ai_system_prompt', $prompt );
377 + }
190 378
191 - const contentTextArea = document.getElementById('betterdocs-ai-content');
192 - const docGenerateBtnTxt = document.querySelector('.generate-btn span');
379 + /**
380 + * Build the system + user messages and chat options shared by the
381 + * Write-with-AI generator and the in-editor AI-Edit endpoint. Routes through
382 + * the active AI platform (ProviderFactory), so it works for every provider.
383 + *
384 + * @param string $prompt
385 + * @param int|null $max_tokens Optional cap override (e.g. a "large" doc).
386 + * @param array $extra_system Optional extra system messages.
387 + * @return array array( array $messages, array $options )
388 + */
389 + private function ai_request_args( $prompt, $max_tokens = null, $extra_system = array() ) {
390 + $messages = array_merge(
391 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
392 + $this->normalize_extra_system( $extra_system ),
393 + array( array( 'role' => 'user', 'content' => $prompt ) )
394 + );
193 395
194 - // Add nonce value to the data being sent
195 - const nonce = document.querySelector('input[name="ai_nonce"]').value;
196 - const isOverwrite = document.getElementById('betterdocs-ai-checkbox').checked;
197 - const generateDocLabel = "<?php echo esc_html__( 'Generate Doc', 'betterdocs' ); ?>";
198 - const reGenerateDocLabel = "<?php echo esc_html__( 'Regenerate Doc', 'betterdocs' ); ?>";
396 + return array( $messages, $this->ai_chat_options( $max_tokens ) );
397 + }
199 398
399 + /**
400 + * Assemble the chat options passed to the active provider. Document
401 + * generation is long-running on every platform: reasoning models (gpt-5*)
402 + * and larger non-OpenAI models (e.g. Claude Opus) routinely need well over
403 + * the old 50s default to return a full doc, which timed them out mid-
404 + * generation. Give every request a generous ceiling; fast models simply
405 + * finish early and are unaffected.
406 + *
407 + * @param int|null $max_tokens Optional token cap override.
408 + * @param float|null $temperature Optional sampling temperature.
409 + * @return array
410 + */
411 + private function ai_chat_options( $max_tokens = null, $temperature = null ) {
412 + $timeout = 300;
413 + if ( function_exists( 'set_time_limit' ) ) {
414 + set_time_limit( 300 );
415 + }
200 416
201 - // contentTextArea.value = `Write an article about ${title} in English. The article is organized by the following exact ${numOfSections} headings. Write exact ${numOfParagraphs} number of paragraphs per heading.${keywords} Use Markdown for formatting. Add an introduction and a conclusion. Style: creative. Tone: cheerful.`;
417 + $options = array(
418 + 'max_tokens' => null !== $max_tokens ? (int) $max_tokens : (int) $this->settings->get( 'ai_autowrite_max_token', 2500 ),
419 + 'context' => 'write_with_ai',
420 + 'timeout' => $timeout,
421 + );
202 422
423 + if ( null !== $temperature ) {
424 + $options['temperature'] = $temperature;
425 + }
203 426
204 - // Check if the title is not empty
205 - if (title.trim() !== '' && keywords.trim() !== '') {
206 - e.preventDefault();
427 + return $options;
428 + }
207 429
208 - if (!jQuery('#betterdocs-ai-error-message').parent().hasClass('hidden')) {
209 - jQuery('#betterdocs-ai-error-message').parent().addClass('hidden');
210 - }
430 + public function generate_openai_response( $prompt, $keywords, $max_tokens = null, $extra_system = array() ) {
431 + try {
432 + list( $messages, $options ) = $this->ai_request_args( $prompt, $max_tokens, $extra_system );
211 433
212 - docGenerateBtnTxt.innerHTML = "<?php echo esc_html__( 'Generating...', 'betterdocs' ); ?>";
213 - jQuery('.generate-btn').prop('disabled', true);
434 + $result = ( new ProviderFactory( $this->settings ) )->make()->chat( $messages, $options );
214 435
215 - jQuery.post({
216 - url: ajaxurl, // Make sure ajaxurl is defined in your script
217 - type: 'POST',
218 - data: {
219 - action: 'generate_openai_content',
220 - prompt: document.querySelector('#betterdocs-ai-content').value,
221 - // numOfSections: numOfSections,
222 - // numOfParagraphs: numOfParagraphs,
223 - keywords: keywords,
224 - ai_nonce: nonce, // Pass the nonce value
225 - },
226 - success: function(response) {
227 - // Update the content in the textarea
436 + if ( is_wp_error( $result ) ) {
437 + return $result->get_error_message();
438 + }
228 439
229 - if (response.success && (typeof response.data === 'string')) {
230 - docGenerateBtnTxt.innerHTML = "<?php echo esc_html__( 'Generated', 'betterdocs' ); ?>";
231 - setTimeout(() => {
232 - insertContentToEditor(title, response.data, isOverwrite, keywords);
233 - docGenerateBtnTxt.innerHTML = reGenerateDocLabel;
234 - jQuery('.generate-btn').removeAttr('disabled');
235 - }, 2000);
236 - } else {
237 - // alert(response.data.message);
238 - jQuery('#betterdocs-ai-error-message').parent().removeClass('hidden');
239 - jQuery('#betterdocs-ai-error-message').html(responseMessage(response.data.message));
240 - docGenerateBtnTxt.innerHTML = generateDocLabel;
241 - jQuery('.generate-btn').removeAttr('disabled');
440 + return $result['content'];
441 + } catch ( \Exception $error ) {
442 + return 'Error: ' . $error->getMessage();
443 + }
444 + }
242 445
243 - }
244 - },
245 - error: function(error) {
246 - console.error(error);
247 - },
248 - });
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 + }
249 467
250 - }
251 - }
468 + $factory = new ProviderFactory( $this->settings );
469 + $platform = $factory->active_platform();
470 + $model = $factory->active_model( $platform );
252 471
253 - // Function to update the textarea
254 - function updateTextarea() {
255 - const title = document.getElementById('betterdocs-ai-title').value;
256 - const keywords = document.getElementById('betterdocs-ai-keyword').value;
257 - const sectionOptions = document.getElementById('bd-autowrtite-content-section');
258 - let language = 'English';
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 + }
259 483
260 - if (language === '') {
261 - language = 'English';
262 - }
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 + );
263 498
264 - let keywordsText = keywords !== '' ? ` and incorporate the keywords: ${keywords}` : '';
499 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( $max_tokens ) );
265 500
266 - const contentTextArea = document.getElementById('betterdocs-ai-content');
501 + if ( is_wp_error( $result ) ) {
502 + return $result;
503 + }
267 504
268 - if (!jQuery('#betterdocs-ai-error-message').parent().hasClass('hidden')) {
269 - jQuery('#betterdocs-ai-error-message').parent().addClass('hidden');
270 - }
505 + return $result['content'];
506 + } catch ( \Exception $error ) {
507 + return new \WP_Error( 'ai_vision_failed', 'Error: ' . $error->getMessage() );
508 + }
509 + }
271 510
272 - contentTextArea.value = `Generate a documentation using HTML heading tags for '${title}'. Include relevant details on ${keywords} in the documentation. Ensure all content is enclosed with <p> tags and apply a <span> tag with the class 'highlight' to headings and topic tags for emphasis.`;
273 - }
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();
274 527
275 - function convertMarkdownToHTML(markdownContent) {
276 - // Simple Markdown to HTML conversion (you may need to enhance this based on your needs)
277 - const htmlContent = markdownContent
278 - .replace(/^# (.+)$/gm, '<h1>$1</h1>')
279 - .replace(/^## (.+)$/gm, '<h2>$1</h2>')
280 - .replace(/^### (.+)$/gm, '<h3>$1</h3>')
281 - .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
282 - .replace(/\*(.+?)\*/g, '<em>$1</em>');
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 + }
283 538
284 - return htmlContent;
285 - }
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 );
286 543
287 - function replaceHeadingTitle(content, title, overviewTitle) {
288 - let regex = new RegExp(title, 'g');
289 - let updateContent = content.replace(regex, overviewTitle);
290 - return updateContent;
291 - }
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 + }
292 550
293 - function removeFirstHeading(htmlString) {
294 - var newDiv = document.createElement('div');
295 - newDiv.innerHTML = htmlString;
296 - var firstHeading = newDiv.querySelector('h1');
297 - if (firstHeading) {
298 - firstHeading.parentNode.removeChild(firstHeading);
299 - }
300 - return newDiv.innerHTML;
301 - }
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 );
302 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 + }
303 577
304 - function wrapwithHeighlight(inputString, keywords) {
305 - let modifiedString = inputString.replace(/<(h\d)(.*?)>(.*?)<\/\1>/g, '<$1$2><span class="highlight">$3</span></$1>');
578 + return false;
579 + }
306 580
307 - const keywordArray = keywords.split(',').map(keyword => keyword.trim());
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 + }
308 595
309 - keywordArray.forEach(keyword => {
310 - modifiedString = modifiedString.replace(new RegExp(`\\b(${keyword})\\b`, 'gi'), '<span class="highlight">$1</span>');
311 - });
596 + $model = strtolower( (string) $model );
312 597
313 - return modifiedString;
314 - }
598 + if ( '' === $model || false !== strpos( $model, 'gpt-3.5' ) ) {
599 + return false;
600 + }
315 601
316 - function getBodyContent(htmlString) {
317 - var match = htmlString.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
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 + }
318 607
319 - // Check if match is null before accessing match[1]
320 - if (match) {
321 - return match[1].replace(/<p>\s+/g, '<p>');
322 - } else {
323 - return '';
324 - }
325 - }
608 + return false;
609 + }
326 610
611 + public function get_outline_system_prompt() {
612 + $prompt = <<<'PROMPT'
613 +You are a Senior Technical Writer. Produce a documentation OUTLINE only — not the full article.
327 614
615 +Return ONLY a JSON array (no prose, no markdown, no code fences). Each element is an object:
616 + { "level": "h2" | "h3", "text": "Section heading" }
328 617
329 - function insertContentToEditor(title, htmlContent, isOverwrite, keywords) {
618 +Rules:
619 +- 5 to 9 top-level "h2" sections that comprehensively cover the topic.
620 +- Add "h3" sub-sections only where they genuinely help; keep each one directly after its parent h2.
621 +- Headings are concise, descriptive, and free of numbering.
622 +- Do not include an h1/title and do not include any text outside the JSON array.
623 +PROMPT;
330 624
331 - const {
332 - dispatch,
333 - select
334 - } = wp.data;
625 + return apply_filters( 'betterdocs_write_with_ai_outline_system_prompt', $prompt );
626 + }
335 627
336 - htmlContent = getBodyContent(htmlContent);
337 - htmlContent = removeFirstHeading(htmlContent);
338 - htmlContent = wrapwithHeighlight(htmlContent, keywords);
339 - const isPostContent = `<?php echo isset( $_GET[ 'post' ] ) ? esc_html( get_the_content( $_GET[ 'post' ] ) ) : ''; // phpcs:ignore ?>`;
628 + /**
629 + * Generate a documentation outline as a structured array of sections.
630 + *
631 + * @param string $user_prompt The composed prompt (title/keywords/instructions).
632 + * @return array { success:bool, outline?:array<int,array{level:string,text:string}>, error?:string }
633 + */
634 + public function generate_outline_response( $user_prompt, $extra_system = array() ) {
635 + $result = $this->generate_text( $user_prompt, $this->get_outline_system_prompt(), $extra_system );
340 636
341 - const blocks = wp.blocks.rawHandler({
342 - HTML: htmlContent
343 - });
637 + if ( empty( $result['success'] ) ) {
638 + return array(
639 + 'success' => false,
640 + 'error' => isset( $result['error'] ) ? $result['error'] : 'AI error',
641 + );
642 + }
344 643
345 - dispatch('core/editor').editPost({
346 - title: title,
347 - });
644 + $outline = $this->parse_outline( (string) $result['content'] );
348 645
349 - const selectedBlockClientId = select('core/block-editor').getSelectedBlockClientId();
646 + if ( empty( $outline ) ) {
647 + return array(
648 + 'success' => false,
649 + 'error' => 'The AI returned an outline we could not read. Please try again.',
650 + );
651 + }
350 652
351 - const allBlocks = select('core/block-editor').getBlocks();
653 + return array(
654 + 'success' => true,
655 + 'outline' => $outline,
656 + );
657 + }
352 658
353 - if (isOverwrite || isPostContent === '') {
659 + /**
660 + * Parse a model outline response (ideally a JSON array) into a clean list of
661 + * { level, text } sections. Tolerates code fences and stray prose.
662 + *
663 + * @param string $content
664 + * @return array<int,array{level:string,text:string}>
665 + */
666 + protected function parse_outline( $content ) {
667 + $content = trim( $content );
668 + $content = preg_replace( '/^```(?:json)?\s*/i', '', $content );
669 + $content = preg_replace( '/```\s*$/', '', $content );
354 670
355 - allBlocks.forEach((block) => {
356 - dispatch('core/block-editor').removeBlock(block.clientId);
357 - });
358 - dispatch('core/block-editor').insertBlocks(blocks);
671 + // Grab the first JSON array if the model wrapped it in prose.
672 + if ( preg_match( '/\[[\s\S]*\]/', $content, $m ) ) {
673 + $content = $m[0];
674 + }
359 675
360 - } else if (selectedBlockClientId) {
361 - const selectedIndex = select('core/block-editor').getBlockIndex(selectedBlockClientId);
362 - dispatch('core/block-editor').insertBlocks(blocks, selectedIndex + 1);
363 - } else {
364 - dispatch('core/block-editor').insertBlocks(blocks);
365 - }
676 + $decoded = json_decode( $content, true );
677 + if ( ! is_array( $decoded ) ) {
678 + return array();
679 + }
366 680
367 - closeWriteWithAIForm('afterInsertContent');
368 - }
681 + $outline = array();
682 + foreach ( $decoded as $item ) {
683 + if ( ! is_array( $item ) || empty( $item['text'] ) ) {
684 + continue;
685 + }
686 + $level = isset( $item['level'] ) && 'h3' === strtolower( (string) $item['level'] ) ? 'h3' : 'h2';
687 + $text = sanitize_text_field( (string) $item['text'] );
688 + if ( '' === $text ) {
689 + continue;
690 + }
691 + $outline[] = array( 'level' => $level, 'text' => $text );
692 + }
369 693
370 - function closeWriteWithAIForm(after) {
371 - jQuery('.betterdocs-ai-autowrite-form-container').addClass('hidden');
372 - jQuery('#betterdocs-ai-autowrite-form-container-overlay').addClass('hidden');
373 - jQuery('.betterdocs-ai-button').addClass('regenerate-btn');
374 - }
694 + return $outline;
695 + }
375 696
697 + public function generate_openai_response_ai_edit( $prompt, $extra_system = array() ) {
698 + $factory = new ProviderFactory( $this->settings );
699 + $model = $factory->active_model();
376 700
377 - document.addEventListener('DOMContentLoaded', function() {
701 + $messages = array_merge(
702 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
703 + $this->normalize_extra_system( $extra_system ),
704 + array( array( 'role' => 'user', 'content' => $prompt ) )
705 + );
378 706
379 - // Subscribe to changes in the editor state
380 - let previousDocsTitle = '';
707 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
381 708
382 - wp.data.subscribe(() => {
383 - // Get the updated post title
384 - // const docsTitle = wp.data.select('core/editor').getEditedPostAttribute('title');
709 + if ( is_wp_error( $result ) ) {
710 + return array(
711 + 'success' => false,
712 + 'error' => $result->get_error_message(),
713 + 'model' => $model
714 + );
715 + }
385 716
386 - const docsTitle = wp.data.select('core/editor').getEditedPostAttribute('title');
717 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
387 718
388 - // Check if the title has changed
389 - if (docsTitle !== previousDocsTitle) {
390 - let currentKeywords = jQuery('#betterdocs-ai-keyword').val();
391 - if (!currentKeywords) {
392 - currentKeywords = '{Documentation Keywords}';
393 - }
394 - const currentPrompt = `Generate documentation using HTML heading tags for '${docsTitle?docsTitle:'{Documentation title}'}'. Include relevant details on ${currentKeywords} in the documentation. Ensure all content is enclosed with <p> tags and apply a <span> tag with the class 'highlight' to headings and topic tags for emphasis.`;
395 - jQuery('#betterdocs-ai-content').val(currentPrompt);
719 + return array(
720 + 'success' => true,
721 + 'content' => $result['content'],
722 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
723 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
724 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
725 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
726 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
727 + );
728 + }
396 729
397 - jQuery('#betterdocs-ai-title').val(docsTitle);
730 + /**
731 + * Generic chat completion using the active Write-with-AI platform/model/token
732 + * settings. Shared by lightweight, plain-text generators (glossary definitions,
733 + * FAQ answers, outlines) that need the same dynamic model as Write with AI /
734 + * AI Edit but a caller-supplied system prompt instead of the doc-authoring HTML
735 + * prompt. Routes through ProviderFactory so every provider is supported.
736 + *
737 + * @param string $user_prompt The user message.
738 + * @param string $system_prompt Optional system message (omitted when empty).
739 + * @param array $extra_system Optional extra system messages.
740 + * @return array { success:bool, content?:string, error?:string, model:string, *_tokens?:int }
741 + */
742 + public function generate_text( $user_prompt, $system_prompt = '', $extra_system = array() ) {
743 + $factory = new ProviderFactory( $this->settings );
744 + $model = $factory->active_model();
398 745
399 - previousDocsTitle = docsTitle;
400 - }
746 + $messages = array();
747 + if ( $system_prompt !== '' ) {
748 + $messages[] = array( 'role' => 'system', 'content' => $system_prompt );
749 + }
750 + foreach ( $this->normalize_extra_system( $extra_system ) as $extra ) {
751 + $messages[] = $extra;
752 + }
753 + $messages[] = array( 'role' => 'user', 'content' => $user_prompt );
401 754
755 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
402 756
403 - });
404 - });
757 + if ( is_wp_error( $result ) ) {
758 + return array(
759 + 'success' => false,
760 + 'error' => $result->get_error_message(),
761 + 'model' => $model
762 + );
763 + }
405 764
765 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
406 766
407 - // This example assumes that this code is executed when your script is loaded.
408 - // You may want to place it within the appropriate context in your application.
767 + return array(
768 + 'success' => true,
769 + 'content' => $result['content'],
770 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
771 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
772 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
773 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
774 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
775 + );
776 + }
409 777
778 + /**
779 + * Generate a chat completion with a caller-supplied system + user prompt.
780 + * Mirrors generate_openai_response_ai_edit() (same model/token floor/timeout
781 + * handling and return shape) but does NOT force the documentation-writer
782 + * system prompt, so callers such as the Docs AI Suite (taxonomy suggestions,
783 + * excerpts) can supply task-appropriate instructions. Routes through the active
784 + * provider via ProviderFactory.
785 + *
786 + * @param string $system_prompt System instruction for the model.
787 + * @param string $user_prompt User message / content payload.
788 + * @param float|null $temperature Optional sampling temperature (ignored for gpt-5*).
789 + * @return array{success:bool,content?:string,error?:string,model:string,...}
790 + */
791 + public function generate_openai_response_raw( $system_prompt, $user_prompt, $temperature = null ) {
792 + $factory = new ProviderFactory( $this->settings );
793 + $model = $factory->active_model();
410 794
411 - function writeWithAIForm() {
795 + $messages = array(
796 + array( 'role' => 'system', 'content' => $system_prompt ),
797 + array( 'role' => 'user', 'content' => $user_prompt )
798 + );
412 799
413 - let title = `<?php echo isset( $_GET[ 'post' ] ) ? esc_html( get_the_title( $_GET[ 'post' ] ) ) : ''; // phpcs:ignore ?>`;
414 - if (docsTitle) {
415 - title = docsTitle;
416 - }
800 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( null, $temperature ) );
417 801
418 - const promtTitle = `<?php echo isset( $_GET[ 'post' ] ) ? esc_html( get_the_title( $_GET[ 'post' ] ) ) : '{Documentation Title}'; // phpcs:ignore ?>`;
419 - const titlePlaceholder = "<?php echo esc_attr__( 'Enter a descriptive title for your documentation.', 'betterdocs' ); ?>";
420 - const keywords = "<?php echo esc_attr( '{Documentation Keywords}' ); ?>";
421 - const keywordsPlaceholder = "<?php echo esc_attr__( 'Add keywords to generate precise & relevant documentation (comma-separated).', 'betterdocs' ); ?>";
422 - const language = "<?php echo esc_attr( 'English' ); ?>";
423 - const titleLabel = "<?php echo esc_html__( 'Documentation Title:', 'betterdocs' ); ?>";
424 - const keywordLabel = "<?php echo esc_html__( 'Keywords:', 'betterdocs' ); ?>";
425 - const secLabel = "<?php echo esc_html__( '# of Sections:', 'betterdocs' ); ?>";
426 - const paraLabel = "<?php echo esc_html__( '# of Paragraph Per Section: ', 'betterdocs' ); ?>";
427 - const langLabel = "<?php echo esc_html__( 'Language:', 'betterdocs' ); ?>";
428 - const promtLabel = "<?php echo esc_html__( 'Prompt:', 'betterdocs' ); ?>";
429 - const promtBtnLabel = "<?php echo esc_html__( 'Generate Prompt', 'betterdocs' ); ?>";
430 - let promtArticleLabel = "<?php echo esc_html__( 'Generate Doc', 'betterdocs' ); ?>";
431 - const checkboxLabel = "<?php echo esc_html__( 'Overwrite your existing Doc:', 'betterdocs' ); ?>";
432 - const nonce = "<?php echo esc_attr( wp_create_nonce( 'generate_openai_content_nonce' ) ); ?>";
802 + if ( is_wp_error( $result ) ) {
803 + return array(
804 + 'success' => false,
805 + 'error' => $result->get_error_message(),
806 + 'model' => $model
807 + );
808 + }
433 809
434 - <?php
435 - $is_valid = $this->get_api_key();
436 - $disable_field = 'disabled-input-field';
437 - if ( $this->get_api_key() ) {
438 - $disable_field = '';
439 - }
440 - ?>
810 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
441 811
442 - let hiddenClass = 'hidden';
443 - let newDocPageAtt = 'data-new-doc-page="true"';
444 - const isPostContent = `<?php echo isset( $_GET[ 'post' ] ) ? esc_html( get_the_content( $_GET[ 'post' ] ) ) : ''; // phpcs:ignore ?>`;
812 + return array(
813 + 'success' => true,
814 + 'content' => $result['content'],
815 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
816 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
817 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
818 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
819 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
820 + );
821 + }
445 822
446 - if (isPostContent !== '') {
447 - hiddenClass = '';
448 - newDocPageAtt = '';
449 - }
823 + public function generate_openai_content_callback() {
824 + // Verify the nonce
825 + $ai_nonce = isset( $_POST['ai_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_nonce'] ) ) : '';
826 + if ( ! wp_verify_nonce( $ai_nonce, 'generate_openai_content_nonce' ) ) {
827 + wp_send_json_error( 'Invalid nonce' );
828 + wp_die();
829 + }
450 830
831 + if ( ! current_user_can( 'edit_posts' ) ) {
832 + wp_send_json_error( 'Insufficient permissions' );
833 + wp_die();
834 + }
451 835
452 - // const prompt = `Make a knowledge base article with html heading tags about [${title}] in [${language}]. Here is some topic to describe the article: [${keywords}]. and wrap the content with p tag also add a span tag with a class named 'highlight' to all the heading and topic tags in the article.`;
836 + $prompt = isset( $_POST['prompt'] ) ? sanitize_text_field( wp_unslash( $_POST['prompt'] ) ) : '';
837 + $keywords = isset( $_POST['keywords'] ) ? sanitize_text_field( wp_unslash( $_POST['keywords'] ) ) : '';
453 838
454 - // const prompt = `Create a details knowledge base article in [${language}] about [${title}] with html heading tags. Here is some topic to describe the article: [${keywords}]. And wrap the content with p tag also add a span tag with a class named 'highlight' to all the heading and topic tags in the article.`;
839 + $ai_instance = new WriteWithAI( $this->settings );
455 840
456 - const prompt = `Generate a documentation using HTML heading tags for '${promtTitle}'. Include relevant details on ${keywords} in the documentation. Ensure all content is enclosed with <p> tags and apply a <span> tag with the class 'highlight' to headings and topic tags for emphasis.`;
841 + $generated_content = $ai_instance->generate_openai_response( $prompt, $keywords );
457 842
458 - const aiIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="21" height="20" viewBox="0 0 21 20" fill="none">
459 - <g clip-path="url(#clip0_2460_1729)">
460 - <path d="M10.3956 18.8608L6.10991 19.2322L6.48134 14.9465L15.3956 6.08936C15.5287 5.95329 15.6876 5.84518 15.863 5.77137C16.0384 5.69756 16.2268 5.65954 16.4171 5.65954C16.6074 5.65954 16.7957 5.69756 16.9711 5.77137C17.1466 5.84518 17.3054 5.95329 17.4385 6.08936L19.2528 7.91793C19.3865 8.05083 19.4926 8.20886 19.565 8.38293C19.6375 8.55701 19.6747 8.74368 19.6747 8.93221C19.6747 9.12075 19.6375 9.30742 19.565 9.48149C19.4926 9.65557 19.3865 9.8136 19.2528 9.9465L10.3956 18.8608ZM1.7042 5.67507C1.20277 5.58793 1.20277 4.86793 1.7042 4.78079C2.59203 4.62626 3.41374 4.21085 4.06456 3.58751C4.71538 2.96417 5.16583 2.16113 5.35848 1.28079L5.38848 1.14221C5.49705 0.647929 6.20277 0.643643 6.31705 1.13936L6.35277 1.29936C6.55248 2.17579 7.0067 2.97367 7.65837 3.59281C8.31005 4.21195 9.13013 4.62475 10.0156 4.77936C10.5199 4.86507 10.5199 5.58936 10.0156 5.67793C9.13005 5.83218 8.3098 6.24465 7.65787 6.86354C7.00594 7.48243 6.5514 8.28014 6.35134 9.1565L6.3142 9.31793C6.20134 9.81221 5.49563 9.80936 5.38705 9.31364L5.35848 9.17507C5.16564 8.29433 4.71476 7.49102 4.06339 6.86764C3.41202 6.24426 2.58969 5.82908 1.70134 5.67507H1.7042Z" stroke="white" stroke-width="1.42857" stroke-linecap="round" stroke-linejoin="round"/>
461 - </g>
462 - <defs>
463 - <clipPath id="clip0_2460_1729">
464 - <rect width="20" height="20" fill="white" transform="translate(0.5)"/>
465 - </clipPath>
466 - </defs>
467 - </svg>`;
843 + // Count a successful generation (skip obvious upstream errors).
844 + if ( is_string( $generated_content ) && $generated_content !== '' && strpos( $generated_content, 'Error:' ) !== 0 ) {
845 + $post_id = isset( $_POST[ 'post_id' ] ) ? intval( $_POST[ 'post_id' ] ) : 0; //phpcs:ignore
846 + AIUsage::record( 'write_with_ai', $post_id );
847 + }
468 848
469 -
470 -
471 - return `
472 - <div class="betterdocs-ai-autowrite-form-container hidden" ${newDocPageAtt}>
473 - <div class="betterdocs-ai-autowrite-form-content">
474 - <div class="betterdocs-ai-autowrite-top-part">
475 - <div class="autowrite-heading">
476 - <span class="autowrite-icon">
477 - <svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
478 - <g clip-path="url(#clip0_2453_20882)">
479 - <path d="M15.8322 30.1765L8.97508 30.7708L9.56936 23.9136L23.8322 9.74219C24.0451 9.52448 24.2993 9.3515 24.58 9.23341C24.8606 9.11531 25.162 9.05447 25.4665 9.05447C25.771 9.05447 26.0724 9.11531 26.3531 9.23341C26.6337 9.3515 26.8879 9.52448 27.1008 9.74219L30.0037 12.6679C30.2176 12.8805 30.3874 13.1334 30.5033 13.4119C30.6192 13.6904 30.6788 13.9891 30.6788 14.2908C30.6788 14.5924 30.6192 14.8911 30.5033 15.1696C30.3874 15.4481 30.2176 15.701 30.0037 15.9136L15.8322 30.1765ZM1.92593 9.07933C1.12365 8.9399 1.12365 7.7879 1.92593 7.64848C3.34647 7.40124 4.6612 6.73658 5.70251 5.73923C6.74382 4.74188 7.46455 3.45703 7.77279 2.04848L7.82079 1.82676C7.99451 1.03591 9.12365 1.02905 9.30651 1.82219L9.36365 2.07819C9.68319 3.48048 10.4099 4.75709 11.4526 5.74772C12.4953 6.73834 13.8074 7.39881 15.2242 7.64619C16.0311 7.78333 16.0311 8.94219 15.2242 9.0839C13.8073 9.33071 12.4949 9.99066 11.4518 10.9809C10.4087 11.9711 9.68146 13.2474 9.36136 14.6496L9.30193 14.9079C9.12136 15.6988 7.99222 15.6942 7.81851 14.901L7.77279 14.6793C7.46424 13.2702 6.74284 11.9849 5.70064 10.9874C4.65845 9.99003 3.34273 9.32574 1.92136 9.07933H1.92593Z" stroke="#E31B54" stroke-width="2.28571" stroke-linecap="round" stroke-linejoin="round"/>
480 - </g>
481 - <defs>
482 - <clipPath id="clip0_2453_20882">
483 - <rect width="32" height="32" fill="white"/>
484 - </clipPath>
485 - </defs>
486 - </svg>
487 - </span>
488 - <h1><?php echo esc_html__( 'Write Documentation with BetterDocs AI', 'betterdocs' ); ?></h1>
489 -
490 - </div>
491 - <div class="autowrite-subheadding">
492 - <p><?php echo esc_html__( 'Generate documentation effortlessly with BetterDocs AI. Simply input your doc title, keywords, prompt and let the system automatically generate comprehensive documentation tailored to your needs.', 'betterdocs' ); ?></p>
493 - </div>
494 -
495 - <?php if ( empty( $this->get_api_key() ) ): ?>
496 - <div id="betterdocs-ai-message">
497 - <div class="warning-message">
498 - <span class="dashicons dashicons-warning"></span>
499 - <div>
500 - <?php echo wp_kses_post( 'Please Insert your <a target="_blank" href="' . esc_url( admin_url( 'admin.php?page=betterdocs-settings&tab=tab-betterdocs-ai' ) ) . '">OpenAI API Key</a> to use this Write with AI feature.', 'betterdocs' ); ?>
501 - </div>
502 - </div>
503 -
504 - </div>
505 - <?php endif; ?>
506 -
507 - <div class="form-group hidden">
508 - <div id="betterdocs-ai-error-message"></div>
509 - </div>
510 -
511 - </div>
512 - <form id="betterdocs-ai-form" class="<?php echo esc_attr( $disable_field ); ?>">
513 - <div class="form-inner-content">
514 - <div class="form-group">
515 - <label for="betterdocs-ai-title">${titleLabel}</label>
516 - <input type="text" placeholder="${titlePlaceholder}" id="betterdocs-ai-title" name="betterdocs-ai-title" required value="${title}">
517 - </div>
518 -
519 - <div class="form-group">
520 - <label for="betterdocs-ai-keyword">${keywordLabel}</label>
521 - <input type="text" placeholder="${keywordsPlaceholder}" id="betterdocs-ai-keyword" name="betterdocs-ai-keyword" required>
522 - </div>
523 -
524 - <div class="form-group prompt-field">
525 - <label for="betterdocs-ai-content">${promtLabel}</label>
526 - <textarea id="betterdocs-ai-content" name="betterdocs-ai-content" rows="6" required>${prompt}</textarea>
527 - <p class="input-description"><?php echo esc_html__( 'Ensure you include a clear and detailed prompt to receive the desired output. Follow the guidelines provided for the best results.', 'betterdocs' ); ?></p>
528 - </div>
529 - <div class="betterdocs-ai-checkbox-field ${hiddenClass}">
530 - <div class="form-group">
531 - <div class="checkbox-field-label">
532 - <label for="betterdocs-ai-checkbox">${checkboxLabel}</label>
533 - <p class="input-description"><?php echo esc_html__( 'Overwrite the existing doc with the AI Generated Content. If Disabled, new AI Generated content will be added in the section you are currently editing.', 'betterdocs' ); ?></p>
534 - </div>
535 -
536 - <div class="checkbox-input-field">
537 - <input type="checkbox" id="betterdocs-ai-checkbox" name="betterdocs-ai-checkbox" data-overwrite="false">
538 - <label for="betterdocs-ai-checkbox" class="toggle-label"></label>
539 - </div>
540 - </div>
541 - </div>
542 -
543 - <input type="hidden" name="ai_nonce" value="${nonce}" />
544 - </div>
545 - <div class="generate-button-container">
546 - <button type="submit" class="generate-btn" onclick="generateArticle(event)">
547 - ${aiIcon} <span>${promtArticleLabel}</span>
548 - </button>
549 - </div>
550 - </form>
551 - </div>
552 -
553 - <div class="bd-close-button" onclick="closeWriteWithAIForm('afterClosedClicked')">✕</div>
554 - </div>
555 -
556 - <div id="betterdocs-ai-autowrite-form-container-overlay" class="hidden"></div>
557 - `;
558 - }
559 -
560 - jQuery(document).on('click', '.regenerate-btn', function() {
561 - jQuery('.betterdocs-ai-autowrite-form-container').removeClass('hidden');
562 - jQuery('#betterdocs-ai-autowrite-form-container-overlay').removeClass('hidden');
563 - });
564 -
565 - jQuery(document).ready(function($) {
566 - // Check if we are in the Edit Post screen
567 -
568 - if ($('.block-editor-page').length > 0) {
569 -
570 - const bdAIButton = $(`<div class="betterdocs-ai-button-container">
571 - <button class="betterdocs-ai-button">
572 - <img src="<?php echo esc_url( BETTERDOCS_ABSURL . 'assets/admin/images/betterdocs-icon-white.png' ); ?>" alt="<?php echo esc_attr__( 'betterdocs icon', 'betterdocs' ); ?>" />
573 - <span><?php echo esc_html__( 'Write with AI', 'betterdocs' ); ?></span>
574 - </button>
575 - </div>`);
576 -
577 -
578 - const closeBtn = $('bd-close-button');
579 -
580 - const formHtml = writeWithAIForm();
581 - $(formHtml).addClass('hidden');
582 -
583 - $(document).on('click', '.betterdocs-ai-button', function() {
584 - $('.betterdocs-ai-autowrite-form-container').removeClass('hidden');
585 - $('#betterdocs-ai-autowrite-form-container-overlay').removeClass('hidden');
586 - });
587 -
588 - // $(document).on('click', '.betterdocs-ai-button', function() {
589 - if ($('.betterdocs-ai-autowrite-form-container').length < 1) {
590 - $('body').append(formHtml);
591 -
592 - // Add event listeners to input elements
593 - document.getElementById('betterdocs-ai-title').addEventListener('input', updateTextarea);
594 - document.getElementById('betterdocs-ai-keyword').addEventListener('input', updateTextarea);
595 - // document.getElementById('bd-autowrtite-content-section').addEventListener('change', updateTextarea);
596 - // document.getElementById('bd-autowrtite-content-paragraph').addEventListener('change', updateTextarea);
597 - // document.getElementById('betterdocs-ai-language').addEventListener('input', updateTextarea);
598 - }
599 - // });
600 - const btn = document.createElement('button');
601 - wp.data.subscribe(function() {
602 - setTimeout(() => {
603 - if ($('.edit-post-header__settings').length > 0) {
604 - $('.edit-post-header__settings').prepend(bdAIButton);
605 - } else if ($('.editor-header__settings').length > 0) {
606 - $('.editor-header__settings').prepend(bdAIButton);
607 - }
608 -
609 - }, 1);
610 - });
611 - }
612 -
613 - $(document).on('click', '#betterdocs-ai-autowrite-form-container-overlay', function() {
614 - closeWriteWithAIForm();
615 - });
616 - });
617 - </script>
618 -
619 - <style>
620 - .hidden {
621 - display: none !important;
622 - }
623 -
624 - .disabled-input-field input,
625 - .disabled-input-field textarea,
626 - .disabled-input-field button,
627 - .disabled-input-field label {
628 - pointer-events: none;
629 - opacity: 0.6;
630 - }
631 -
632 - .disabled-input-field label {
633 - opacity: 1;
634 - }
635 -
636 - .betterdocs-ai-button-container {
637 - margin-left: 10px;
638 - white-space: nowrap;
639 - max-width: 145px;
640 - }
641 -
642 - .autowrite-icon {
643 - display: flex;
644 - align-items: center;
645 - width: 55px;
646 - height: 55px;
647 - background: #FFE4E8;
648 - justify-content: center;
649 - border-radius: 50px;
650 - }
651 -
652 - .autowrite-icon svg {
653 - width: 28px;
654 - height: 28px;
655 - }
656 -
657 - .autowrite-heading {
658 - display: flex;
659 - gap: 10px;
660 - align-items: center;
661 - }
662 -
663 - .autowrite-heading h1 {
664 - color: #1D2939;
665 - font-family: 'IBM Plex Sans', sans-serif;
666 - font-size: 24px;
667 - font-style: normal;
668 - font-weight: 500;
669 - line-height: normal;
670 - margin: 0;
671 - }
672 -
673 - .autowrite-heading p,
674 - form#betterdocs-ai-form p {
675 - margin: 0;
676 - margin-bottom: 24px;
677 - }
678 -
679 - .autowrite-subheadding p {
680 - font-family: 'IBM Plex Sans', sans-serif;
681 - font-size: 14px;
682 - }
683 -
684 - .prompt-field .input-description {
685 - margin: 0 !important;
686 - }
687 -
688 - .autowrite-heading p {
689 - color: #475467;
690 - font-family: 'IBM Plex Sans', sans-serif;
691 - font-size: 16px;
692 - font-style: normal;
693 - font-weight: 400;
694 - }
695 -
696 - .betterdocs-ai-button {
697 - background: #00B884;
698 - border: 1px solid transparent;
699 - color: #fff;
700 - box-shadow: none;
701 - font-size: 12px;
702 - margin-right: 8px;
703 - padding: 0px 15px;
704 - cursor: pointer;
705 - border-radius: 3px;
706 - height: 38px;
707 - display: flex;
708 - align-items: center;
709 - justify-content: space-between;
710 - gap: 10px;
711 - width: 135px;
712 - }
713 -
714 - .betterdocs-ai-button img {
715 - width: 20px;
716 - height: 20px;
717 - }
718 -
719 - .betterdocs-ai-autowrite-form-container {
720 - position: fixed;
721 - top: 50%;
722 - left: 50%;
723 - transform: translate(-50%, -50%);
724 - z-index: 100001;
725 - width: 650px;
726 - margin: 0 auto;
727 - background-color: #fff;
728 - border-radius: 5px;
729 - font-family: 'IBM Plex Sans', sans-serif;
730 - /* max-height: 600px; */
731 - max-height: 750px;
732 - max-width: 100%;
733 - }
734 -
735 - .betterdocs-ai-autowrite-form-content {
736 - background-color: #fff;
737 - padding: 20px 0px;
738 - border-radius: 5px;
739 - padding-bottom: 0;
740 - overflow: auto;
741 - /* max-height: 700px; */
742 - height: 100%;
743 -
744 - }
745 -
746 - .betterdocs-ai-autowrite-top-part {
747 - /* padding-bottom: 20px; */
748 - border-bottom: 1px solid #ddd;
749 - /* margin-bottom: 20px; */
750 - padding: 0 40px;
751 - }
752 -
753 - #betterdocs-ai-form {
754 - display: flex;
755 - flex-direction: column;
756 - /* height: 100%; */
757 - overflow: hidden;
758 - }
759 -
760 - .form-inner-content {
761 - overflow: auto;
762 - height: 100%;
763 - max-height: 435px;
764 - /* max-height: 330px; */
765 - padding: 0 40px;
766 - }
767 -
768 - .form-inner-content>.form-group {
769 - margin-top: 20px;
770 - }
771 -
772 -
773 - #betterdocs-ai-autowrite-form-container-overlay {
774 - position: fixed;
775 - top: 0;
776 - right: 0;
777 - bottom: 0;
778 - left: 0;
779 - background-color: rgba(0, 0, 0, .7);
780 - z-index: 100000;
781 - }
782 -
783 -
784 -
785 - #betterdocs-ai-form {
786 - display: flex;
787 - flex-direction: column;
788 - }
789 -
790 - #betterdocs-ai-form .form-group {
791 - margin-bottom: 24px;
792 - }
793 -
794 - #betterdocs-ai-form .bd-aiform-flex {
795 - display: flex;
796 - justify-content: space-between;
797 - }
798 -
799 - #betterdocs-ai-form label {
800 - font-weight: 600;
801 - margin-bottom: 5px;
802 - display: block;
803 - font-size: 14px;
804 - line-height: 20px;
805 - }
806 -
807 - select#bd-autowrtite-content-section,
808 - #bd-autowrtite-content-paragraph,
809 - #betterdocs-ai-language {
810 - width: 150px !important;
811 - height: 40px;
812 - }
813 -
814 - #betterdocs-ai-form input[type="text"],
815 - #betterdocs-ai-form textarea {
816 - width: 100%;
817 - padding: 8px;
818 - box-sizing: border-box;
819 - border: 1px solid #D0D5DD;
820 - border-radius: 4px;
821 - color: #667085;
822 - font-weight: 400;
823 - }
824 -
825 - /* Override autofill text color */
826 - #betterdocs-ai-form input:-webkit-autofill {
827 - -webkit-text-fill-color: #667085 !important;
828 - }
829 -
830 - #betterdocs-ai-form input::placeholder {
831 - color: #acb2bf;
832 - }
833 -
834 - #betterdocs-ai-form textarea {
835 - color: #667085;
836 - }
837 -
838 - #betterdocs-ai-form input:focus,
839 - #betterdocs-ai-form textarea:focus {
840 - box-shadow: none;
841 - }
842 -
843 - #betterdocs-ai-form textarea:focus {
844 - color: inherit;
845 - box-shadow: none;
846 -
847 - }
848 -
849 -
850 - .generate-button-container {
851 - position: sticky;
852 - bottom: 0px;
853 - width: 100%;
854 - margin-left: 0;
855 - z-index: 999999 !important;
856 - padding: 20px 0;
857 - display: flex;
858 - justify-content: end;
859 - border-top: 1px solid #ddd;
860 - background: white;
861 - border-bottom-right-radius: 5px;
862 - border-bottom-left-radius: 5px;
863 - }
864 -
865 - .generate-button-container button {
866 - display: flex;
867 - gap: 8px;
868 - align-items: center;
869 - justify-content: center;
870 - opacity: 1 !important;
871 - margin-right: 40px;
872 - }
873 -
874 - .input-description {
875 - font-size: 14px;
876 - color: #667085;
877 - }
878 -
879 - .betterdocs-ai-checkbox-field .form-group {
880 - display: flex;
881 - align-items: start;
882 - /* gap: 200px; */
883 - justify-content: space-between;
884 - }
885 -
886 - .betterdocs-ai-checkbox-field input {
887 - width: 20px;
888 - height: 20px;
889 - }
890 -
891 - /* Add this CSS to your existing stylesheet or create a new one */
892 -
893 - .betterdocs-ai-checkbox-field {
894 - position: relative;
895 - font-size: 16px;
896 - /* Adjust font size as needed */
897 - }
898 -
899 - .betterdocs-ai-checkbox-field input[type=checkbox]:checked::before {
900 - padding: 2px;
901 - }
902 -
903 - .betterdocs-ai-checkbox-field input[type=checkbox] {
904 - border: 1px solid #00b884;
905 - }
906 -
907 - .betterdocs-ai-checkbox-field input[type=checkbox]:checked::before {
908 - content: '\2713';
909 - color: #00b884;
910 - }
911 -
912 - /* Change the default checkbox color */
913 - .betterdocs-ai-checkbox-field input[type="checkbox"]:hover {
914 - -webkit-appearance: none;
915 - /* WebKit/Blink Browsers */
916 - -moz-appearance: none;
917 - /* Firefox */
918 - appearance: none;
919 - border: 1px solid #00b884;
920 - /* Set the height of the checkbox */
921 - /* Optional: Round the corners */
922 - outline: none;
923 - /* Remove the default outline */
924 - }
925 -
926 - /* Style the checked state */
927 - .betterdocs-ai-checkbox-field input[type="checkbox"]:checked {
928 - /* Set the background color when checked */
929 - border: 1px solid #00b884;
930 - /* Set the border color when checked */
931 - }
932 -
933 - .checkbox-field-label {
934 - width: calc(100% - 150px);
935 - }
936 -
937 - .checkbox-field-label p {
938 - margin: 0 !important;
939 - }
940 -
941 - .checkbox-input-field {
942 - width: 300px;
943 - text-align: right;
944 - }
945 -
946 - .checkbox-input-field {
947 - position: relative;
948 - display: inline-block;
949 - width: 60px;
950 - height: 34px;
951 - }
952 -
953 - .checkbox-input-field input {
954 - display: none;
955 - }
956 -
957 - .toggle-label {
958 - position: absolute;
959 - cursor: pointer;
960 - top: 0;
961 - left: 0;
962 - right: 0;
963 - bottom: 0;
964 - background-color: #ccc;
965 - border-radius: 34px;
966 - transition: background-color 0.3s;
967 - scale: .9;
968 - width: 52px;
969 - height: 26px;
970 - }
971 -
972 -
973 - .checkbox-input-field input:checked+.toggle-label {
974 - background-color: #00b884;
975 - }
976 -
977 - .toggle-label:after {
978 - content: "";
979 - position: absolute;
980 - height: 22px;
981 - width: 22px;
982 - left: 2px;
983 - bottom: 2px;
984 - background-color: white;
985 - border-radius: 50%;
986 - transition: transform 0.3s;
987 - }
988 -
989 - .checkbox-input-field input:checked+.toggle-label:after {
990 - transform: translateX(26px);
991 - }
992 -
993 - .generate-btn,
994 - .prompt-btn,
995 - .keep-btn {
996 - background-color: #00b884;
997 - color: #fff;
998 - border: none;
999 - padding: 10px 15px;
1000 - text-align: center;
1001 - text-decoration: none;
1002 - display: inline-block;
1003 - font-size: 16px;
1004 - cursor: pointer;
1005 - border-radius: 4px;
1006 - }
1007 -
1008 - .generate-btn[disabled] {
1009 - cursor: unset;
1010 - }
1011 -
1012 - .bd-close-button {
1013 - cursor: pointer;
1014 - font-size: 18px;
1015 - font-weight: bold;
1016 - float: right;
1017 - position: absolute;
1018 - top: -16px;
1019 - border-radius: 50%;
1020 - background: #ffffff;
1021 - width: 40px;
1022 - height: 40px;
1023 - display: flex;
1024 - align-items: center;
1025 - justify-content: center;
1026 - color: #281617;
1027 - right: -42px;
1028 - }
1029 -
1030 - div#betterdocs-ai-error-message,
1031 - #betterdocs-ai-message {
1032 - font-size: 14px;
1033 - font-family: 'IBM Plex Sans', sans-serif;
1034 - color: #667085;
1035 - margin-bottom: 20px;
1036 - }
1037 -
1038 - div#betterdocs-ai-error-message a,
1039 - #betterdocs-ai-message a {
1040 - text-decoration: none;
1041 - font-weight: 600;
1042 - }
1043 -
1044 - div#betterdocs-ai-error-message a:focus,
1045 - #betterdocs-ai-message a:focus {
1046 - outline: none;
1047 - box-shadow: none;
1048 - color: #2271b1;
1049 - }
1050 -
1051 - #betterdocs-ai-message span {
1052 - color: #d63638;
1053 - }
1054 -
1055 - .warning-message {
1056 - display: flex;
1057 - align-items: center;
1058 - gap: 10px;
1059 - background: #fbebed;
1060 - padding: 12px;
1061 - border-radius: 5px;
1062 - font-size: 14px;
1063 - line-height: 1.4em;
1064 - border-left: 4px solid #d63638;
1065 - }
1066 -
1067 - .warning-message svg {
1068 - width: 20px;
1069 - height: 20px;
1070 - }
1071 -
1072 - .warning-message span {
1073 - color: #d63638;
1074 - }
1075 -
1076 - .warning-message .footer-message {
1077 - width: calc(100% - 20px);
1078 - }
1079 -
1080 - @media only screen and (max-width: 991px) {
1081 - .betterdocs-ai-autowrite-form-container {
1082 - left: 0;
1083 - right: 0;
1084 - transform: translate(0%, -50%);
1085 - }
1086 -
1087 - .betterdocs-ai-autowrite-form-content {
1088 - overflow-x: auto;
1089 - }
1090 - }
1091 -
1092 - @media only screen and (max-width: 740px) {
1093 -
1094 - /* .bd-close-button {
1095 - right: px;
1096 - top: 1px;
1097 - border-radius: 5px;
1098 - width: 12px;
1099 - height: 12px;
1100 - color: #ffffff;
1101 - background: #00b884;
1102 - } */
1103 - .bd-close-button {
1104 - right: 0px;
1105 - top: 0px;
1106 - width: 12px;
1107 - height: 12px;
1108 - font-size: 14px;
1109 - }
1110 -
1111 - }
1112 - </style>
1113 - <?php
1114 - }
1115 - }
849 + // Send the generated content as the AJAX response
850 + wp_send_json_success( $generated_content );
851 + wp_die();
852 + }
853 +}