PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.2
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.2
4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 3.4.2 3.5.0 3.5.1 All 200 releases
← All changes | includes/Core/WriteWithAI.php +564 -1288 4.6.14.9.2 View file →
@@ -12,9 +12,12 @@
12 12 use WPDeveloper\BetterDocs\Core\PostType;
13 13
14 14 use WPDeveloper\BetterDocs\Utils\Helper;
15 15 use WPDeveloper\BetterDocs\Utils\AIHelper;
16 + use WPDeveloper\BetterDocs\AI\ProviderFactory;
17 + use WPDeveloper\BetterDocs\AI\ModelRegistry;
16 18 use WPDeveloper\BetterDocs\Utils\AIUsage;
19 + use WPDeveloper\BetterDocs\REST\AIEdit;
17 20
18 21 class WriteWithAI extends Base {
19 22
20 23 public $settings;
@@ -32,11 +35,11 @@
32 35 $post_type = '';
33 36 }
34 37
35 38 if ( ! empty( $this->isEnabledWriteWithAI() ) && 'docs' == $post_type ) {
36 - add_action( 'admin_footer', array( $this, 'ai_autowrite_button' ) );
37 39 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_ai_edit_assets' ) );
38 40 }
41 + // Legacy AJAX handler kept for back-compat; the redesigned modal uses the REST route.
39 42 add_action( 'wp_ajax_generate_openai_content', array( $this, 'generate_openai_content_callback' ) );
40 43 }
41 44
42 45 public function enqueue_ai_edit_assets( $hook ) {
@@ -48,9 +51,78 @@
48 51 if ( 'docs' !== $post_type ) {
49 52 return;
50 53 }
51 54
52 - if ( empty( $this->get_api_key() ) ) {
55 + $factory = new ProviderFactory( $this->settings );
56 + $api_key = $factory->api_key_for( $factory->active_platform() );
57 + $has_key = ! empty( $api_key );
58 +
59 + // Resolve the *active* AI platform + model (multi-platform aware) so the
60 + // modal reflects the current Settings → AI selection instead of the legacy
61 + // OpenAI-only `write_with_ai_model` key.
62 + $active_platform = $factory->active_platform();
63 + $active_model = $factory->active_model();
64 + $platform_labels = ModelRegistry::platforms();
65 + $model_labels = ModelRegistry::models( $active_platform );
66 +
67 + // 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
71 + // return anything. Mirrors the Docs-AI-suite availability check in Core\DocsAISuite.
72 + $glossary_count = wp_count_terms( array( 'taxonomy' => 'glossaries', 'hide_empty' => false ) );
73 + $has_glossary_terms = ! is_wp_error( $glossary_count ) && (int) $glossary_count > 0;
74 + $glossary_suggestions_enabled = (bool) $this->settings->get( 'enable_glossaries', false )
75 + && (bool) $this->settings->get( 'show_glossary_suggestions', true )
76 + && betterdocs()->is_pro_active()
77 + && $has_glossary_terms;
78 +
79 + // Write with AI — loads even without a key so the modal can show its
80 + // "add an API key" banner (matches the legacy inline form behavior).
81 + betterdocs()->assets->enqueue( 'betterdocs-write-with-ai', 'blocks/write-with-ai.js' );
82 + betterdocs()->assets->enqueue( 'betterdocs-write-with-ai-style', 'blocks/write-with-ai-style.css' );
83 +
84 + wp_localize_script(
85 + 'betterdocs-write-with-ai',
86 + 'betterdocsWriteWithAI',
87 + array(
88 + 'rest_url' => esc_url_raw( rest_url( 'betterdocs/v1/write-with-ai' ) ),
89 + 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
90 + 'post_id' => get_the_ID(),
91 + 'has_key' => $has_key,
92 + // Term-suggestion (Docs AI Suite) wiring reused by the Write-with-AI
93 + // preview step. Endpoint + gate mirror REST\DocsAISuite / Core\DocsAISuite.
94 + 'rest_suggest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-suggest-terms' ) ),
95 + 'suggest_terms_enabled' => (bool) $this->settings->get( 'enable_docs_ai_suite', true ),
96 + // Glossary suggestions follow the glossary feature AND real availability
97 + // (Pro active + at least one glossary term); see the computation above.
98 + 'glossary_suggestions_enabled' => $glossary_suggestions_enabled,
99 + 'platform' => $active_platform,
100 + 'platform_label' => isset( $platform_labels[ $active_platform ] ) ? $platform_labels[ $active_platform ] : ucfirst( (string) $active_platform ),
101 + 'model' => $active_model,
102 + 'model_label' => isset( $model_labels[ $active_model ] ) ? $model_labels[ $active_model ] : $active_model,
103 + 'max_token' => (int) $this->settings->get( 'ai_autowrite_max_token', 2500 ),
104 + 'settings_url' => esc_url( admin_url( 'admin.php?page=betterdocs-settings#betterdocs-ai' ) ),
105 + 'woo_active' => class_exists( 'WooCommerce' ),
106 + 'is_multilingual_active' => Helper::is_multilingual_active(),
107 + 'language_options' => Helper::get_active_languages(),
108 + 'instructions' => $this->get_instruction_choices(),
109 + // From Git is a Pro feature. Pro is detected here; Git enabled/auth
110 + // status is filled in by Pro via the filter below (default: off).
111 + 'is_pro_active' => betterdocs()->is_pro_active(),
112 + 'git' => apply_filters(
113 + 'betterdocs_write_with_ai_git',
114 + array(
115 + 'enabled' => false,
116 + 'connected' => false,
117 + 'settings_url' => admin_url( 'admin.php?page=betterdocs-settings#git-sync' ),
118 + )
119 + ),
120 + )
121 + );
122 +
123 + // AI Edit — only meaningful with a key configured.
124 + if ( ! $has_key ) {
53 125 return;
54 126 }
55 127
56 128 betterdocs()->assets->enqueue( 'betterdocs-ai-edit', 'blocks/ai-edit.js' );
@@ -61,9 +133,11 @@
61 133 'betterdocsAIEdit',
62 134 array(
63 135 'rest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-edit' ) ),
64 136 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
65 - 'post_id' => get_the_ID()
137 + 'post_id' => get_the_ID(),
138 + 'instructions' => $this->get_instruction_choices(),
139 + 'actions' => AIEdit::get_localized_actions()
66 140 )
67 141 );
68 142 }
69 143
@@ -73,58 +147,34 @@
73 147 }
74 148
75 149 public function isValidAPIKey( $apiKey ) {
76 150 if ( empty( $apiKey ) ) {
77 - $api_response[ 'valid' ] = false;
78 - $api_response[ 'message' ] = 'Please Insert your <a href="/admin.php?page=betterdocs-settings#betterdocs-ai">OpenAI API Key</a> to use this Write with AI feature.';
79 -
80 - return $api_response;
151 + return array(
152 + 'valid' => false,
153 + 'message' => 'Please Insert your <a href="/admin.php?page=betterdocs-settings#betterdocs-ai">API Key</a> to use this Write with AI feature.'
154 + );
81 155 }
82 156
83 - $api_response = array();
84 -
85 - $response = wp_safe_remote_get(
86 - 'https://api.openai.com/v1/engines',
87 - array(
88 - 'headers' => array(
89 - 'Content-Type' => 'application/json',
90 - 'Authorization' => 'Bearer ' . $apiKey,
91 - ),
92 - 'timeout' => 15,
93 - )
94 - );
95 -
96 - if ( is_wp_error( $response ) ) {
97 - $api_response[ 'valid' ] = false;
98 - $api_response[ 'message' ] = $response->get_error_message();
99 - return $api_response;
100 - }
101 -
102 - $httpCode = (int) wp_remote_retrieve_response_code( $response );
103 - $body = wp_remote_retrieve_body( $response );
104 -
105 - if ( 200 === $httpCode ) {
106 - $api_response[ 'valid' ] = true;
107 - $api_response[ 'message' ] = 'Valid API Key';
108 - } else {
109 - $responseData = json_decode( $body, true );
110 - $messageData = ! empty( $responseData[ 'error' ] ) ? $responseData[ 'error' ] : array();
111 - $api_response[ 'valid' ] = false;
112 - $api_response[ 'message' ] = ! empty( $messageData[ 'message' ] ) ? $messageData[ 'message' ] : 'Invalid API Key';
113 - }
114 -
115 - // print_r($response);
116 -
117 - return $api_response;
157 + $factory = new ProviderFactory( $this->settings );
158 + return $factory->validate( $factory->active_platform(), $apiKey );
118 159 }
119 160
120 161 public function get_api_key() {
121 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
122 - return $api_key;
162 + $factory = new ProviderFactory( $this->settings );
163 + return $factory->api_key_for( $factory->active_platform() );
123 164 }
124 165
125 - public function get_system_prompt() {
126 - $prompt = <<<'PROMPT'
166 + /**
167 + * The built-in "Default" instruction body.
168 + *
169 + * Single source of truth shared by {@see Settings::get_default()} (which seeds
170 + * the editable "Default" instruction set) and {@see self::get_system_prompt()}
171 + * (the fallback when no saved Default content exists).
172 + *
173 + * @return string
174 + */
175 + public static function default_instruction_content() {
176 + return <<<'PROMPT'
127 177 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.
128 178
129 179 ## Output format
130 180
@@ -146,228 +196,550 @@
146 196 Do not emit `<style>`, `<script>`, `<iframe>`, `<form>`, or inline `style=""` / `class=""` attributes (other than the `highlight` class above).
147 197
148 198 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.
149 199 PROMPT;
150 -
151 - return apply_filters( 'betterdocs_write_with_ai_system_prompt', $prompt );
152 200 }
153 201
154 - public function generate_openai_response( $prompt, $keywords ) {
155 - try {
156 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
157 - $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
158 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
202 + /**
203 + * The saved instruction sets, normalized to a list of { id, title, content }.
204 + *
205 + * Stored in the `write_with_ai_instructions` setting. Always returns at least
206 + * the built-in "Default" set so callers never have to special-case an empty
207 + * option (e.g. a site whose stored value predates this feature).
208 + *
209 + * @return array<int,array{id:string,title:string,content:string}>
210 + */
211 + public function get_instructions() {
212 + $stored = $this->settings->get( 'write_with_ai_instructions', array() );
159 213
160 - $api_endpoint = 'https://api.openai.com/v1/chat/completions'; // Update the endpoint based on OpenAI API version
214 + $instructions = array();
215 + if ( is_array( $stored ) ) {
216 + foreach ( $stored as $item ) {
217 + if ( ! is_array( $item ) || empty( $item['id'] ) ) {
218 + continue;
219 + }
220 + $instructions[] = array(
221 + 'id' => sanitize_key( (string) $item['id'] ),
222 + 'title' => isset( $item['title'] ) ? (string) $item['title'] : '',
223 + 'content' => isset( $item['content'] ) ? (string) $item['content'] : '',
224 + );
225 + }
226 + }
161 227
162 - $request_body = AIHelper::build_openai_payload(
163 - $model,
228 + // Guarantee a Default set is always present.
229 + $has_default = false;
230 + foreach ( $instructions as $item ) {
231 + if ( 'default' === $item['id'] ) {
232 + $has_default = true;
233 + break;
234 + }
235 + }
236 + if ( ! $has_default ) {
237 + array_unshift(
238 + $instructions,
164 239 array(
165 - array(
166 - 'role' => 'system',
167 - 'content' => $this->get_system_prompt()
168 - ),
169 - array(
170 - 'role' => 'user',
171 - 'content' => $prompt
172 - )
173 - ),
174 - $max_tokens,
175 - null,
176 - 'write_with_ai'
240 + 'id' => 'default',
241 + 'title' => __( 'Default/Core', 'betterdocs' ),
242 + 'content' => self::default_instruction_content(),
243 + )
177 244 );
245 + }
178 246
179 - $request_options = array(
180 - 'headers' => array(
181 - 'Content-Type' => 'application/json',
182 - 'Authorization' => 'Bearer ' . $api_key
183 - ),
184 - 'body' => json_encode( $request_body ),
185 - 'timeout' => 300
247 + return $instructions;
248 + }
249 +
250 + /**
251 + * The selectable (non-default) instruction sets exposed to the editor UI.
252 + *
253 + * Only id + title are sent to the browser; the prompt body stays server-side
254 + * and is resolved at request time by {@see self::get_instruction_messages()}.
255 + *
256 + * @return array<int,array{id:string,title:string}>
257 + */
258 + public function get_instruction_choices() {
259 + $choices = array();
260 + foreach ( $this->get_instructions() as $item ) {
261 + if ( 'default' === $item['id'] ) {
262 + continue;
263 + }
264 + if ( '' === trim( $item['content'] ) ) {
265 + continue; // skip empty sets — they'd inject nothing.
266 + }
267 + $choices[] = array(
268 + 'id' => $item['id'],
269 + 'title' => '' !== trim( $item['title'] ) ? $item['title'] : $item['id'],
186 270 );
271 + }
272 + return $choices;
273 + }
187 274
188 - // GPT-5.5 reasoning can run well past the default limits; give PHP and
189 - // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
190 - if ( function_exists( 'set_time_limit' ) ) {
191 - set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running AI generation needs an extended limit; still bounded by server fpm/nginx timeouts.
275 + /**
276 + * Resolve selected instruction ids into extra `system` messages.
277 + *
278 + * The "Default" set is intentionally excluded — it is always sent as the base
279 + * system prompt by {@see self::get_system_prompt()}. Unknown or empty ids are
280 + * silently ignored. Output order follows the requested $ids order.
281 + *
282 + * @param array $ids
283 + * @return array<int,array{role:string,content:string}>
284 + */
285 + public function get_instruction_messages( $ids ) {
286 + if ( empty( $ids ) || ! is_array( $ids ) ) {
287 + return array();
288 + }
289 +
290 + $by_id = array();
291 + foreach ( $this->get_instructions() as $item ) {
292 + $by_id[ $item['id'] ] = $item;
293 + }
294 +
295 + $messages = array();
296 + $seen = array();
297 + foreach ( $ids as $id ) {
298 + $id = sanitize_key( (string) $id );
299 + if ( 'default' === $id || isset( $seen[ $id ] ) || ! isset( $by_id[ $id ] ) ) {
300 + continue;
192 301 }
302 + $content = trim( $by_id[ $id ]['content'] );
303 + if ( '' === $content ) {
304 + continue;
305 + }
306 + $seen[ $id ] = true;
307 + $messages[] = array(
308 + 'role' => 'system',
309 + 'content' => $content,
310 + );
311 + }
193 312
194 - $response = wp_remote_post( $api_endpoint, $request_options );
313 + return $messages;
314 + }
195 315
196 - if ( is_wp_error( $response ) ) {
197 - return 'Error: ' . $response->get_error_message();
316 + /**
317 + * Coerce a list of extra messages into well-formed `system` chat messages.
318 + *
319 + * Accepts either pre-built [ 'role'=>'system', 'content'=>… ] entries (as
320 + * returned by {@see self::get_instruction_messages()}) or bare strings, and
321 + * drops anything empty. Keeps the OpenAI payload builders tolerant of either
322 + * shape so callers can pass instruction ids or ready-made messages.
323 + *
324 + * @param array $extra_system
325 + * @return array<int,array{role:string,content:string}>
326 + */
327 + protected function normalize_extra_system( $extra_system ) {
328 + if ( empty( $extra_system ) || ! is_array( $extra_system ) ) {
329 + return array();
330 + }
331 +
332 + $messages = array();
333 + foreach ( $extra_system as $entry ) {
334 + if ( is_string( $entry ) ) {
335 + $content = trim( $entry );
336 + $role = 'system';
337 + } elseif ( is_array( $entry ) && isset( $entry['content'] ) ) {
338 + $content = trim( (string) $entry['content'] );
339 + $role = isset( $entry['role'] ) ? (string) $entry['role'] : 'system';
198 340 } else {
199 - $body = wp_remote_retrieve_body( $response );
341 + continue;
342 + }
200 343
201 - $data = json_decode( $body, true );
344 + if ( '' === $content ) {
345 + continue;
346 + }
202 347
203 - if ( ! empty( $data[ 'error' ] ) ) {
204 - return $data[ 'error' ][ 'message' ];
205 - }
348 + $messages[] = array(
349 + 'role' => $role,
350 + 'content' => $content,
351 + );
352 + }
206 353
207 - return $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ]; // Update this line to get the assistant's message
354 + return $messages;
355 + }
356 +
357 + public function get_system_prompt() {
358 + // Prefer the saved (editable) "Default" instruction set; fall back to the
359 + // built-in body when it's missing or has been cleared.
360 + $prompt = self::default_instruction_content();
361 + foreach ( $this->get_instructions() as $item ) {
362 + if ( 'default' === $item['id'] && '' !== trim( $item['content'] ) ) {
363 + $prompt = $item['content'];
364 + break;
208 365 }
209 - } catch ( Exception $error ) {
366 + }
367 +
368 + return apply_filters( 'betterdocs_write_with_ai_system_prompt', $prompt );
369 + }
370 +
371 + /**
372 + * Build the system + user messages and chat options shared by the
373 + * Write-with-AI generator and the in-editor AI-Edit endpoint. Routes through
374 + * the active AI platform (ProviderFactory), so it works for every provider.
375 + *
376 + * @param string $prompt
377 + * @param int|null $max_tokens Optional cap override (e.g. a "large" doc).
378 + * @param array $extra_system Optional extra system messages.
379 + * @return array array( array $messages, array $options )
380 + */
381 + private function ai_request_args( $prompt, $max_tokens = null, $extra_system = array() ) {
382 + $messages = array_merge(
383 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
384 + $this->normalize_extra_system( $extra_system ),
385 + array( array( 'role' => 'user', 'content' => $prompt ) )
386 + );
387 +
388 + return array( $messages, $this->ai_chat_options( $max_tokens ) );
389 + }
390 +
391 + /**
392 + * Assemble the chat options passed to the active provider. Document
393 + * generation is long-running on every platform: reasoning models (gpt-5*)
394 + * and larger non-OpenAI models (e.g. Claude Opus) routinely need well over
395 + * the old 50s default to return a full doc, which timed them out mid-
396 + * generation. Give every request a generous ceiling; fast models simply
397 + * finish early and are unaffected.
398 + *
399 + * @param int|null $max_tokens Optional token cap override.
400 + * @param float|null $temperature Optional sampling temperature.
401 + * @return array
402 + */
403 + private function ai_chat_options( $max_tokens = null, $temperature = null ) {
404 + $timeout = 300;
405 + if ( function_exists( 'set_time_limit' ) ) {
406 + set_time_limit( 300 );
407 + }
408 +
409 + $options = array(
410 + 'max_tokens' => null !== $max_tokens ? (int) $max_tokens : (int) $this->settings->get( 'ai_autowrite_max_token', 2500 ),
411 + 'context' => 'write_with_ai',
412 + 'timeout' => $timeout,
413 + );
414 +
415 + if ( null !== $temperature ) {
416 + $options['temperature'] = $temperature;
417 + }
418 +
419 + return $options;
420 + }
421 +
422 + public function generate_openai_response( $prompt, $keywords, $max_tokens = null, $extra_system = array() ) {
423 + try {
424 + list( $messages, $options ) = $this->ai_request_args( $prompt, $max_tokens, $extra_system );
425 +
426 + $result = ( new ProviderFactory( $this->settings ) )->make()->chat( $messages, $options );
427 +
428 + if ( is_wp_error( $result ) ) {
429 + return $result->get_error_message();
430 + }
431 +
432 + return $result['content'];
433 + } catch ( \Exception $error ) {
210 434 return 'Error: ' . $error->getMessage();
211 435 }
212 436 }
213 437
214 - public function generate_openai_response_ai_edit( $prompt ) {
215 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
216 - $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
217 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
438 + /**
439 + * Generate documentation from an uploaded image using a vision-capable model.
440 + *
441 + * Mirrors generate_openai_response() but sends the picture alongside the text
442 + * prompt as an OpenAI-format multimodal user message
443 + * (`content: [ {type:text}, {type:image_url} ]`). The OpenAI-compatible
444 + * provider forwards that message array to the wire verbatim, so no provider
445 + * change is needed. Only OpenAI vision models are wired — Claude and Gemini
446 + * use a different image envelope, so they are refused with a clear error
447 + * instead of being sent a payload they would reject.
448 + *
449 + * @param string $prompt Composed instruction prompt.
450 + * @param array $image { data_uri:string, mime:string }.
451 + * @param int|null $max_tokens Optional token cap.
452 + * @param array $extra_system Extra system messages (instruction sets).
453 + * @return string|\WP_Error Generated content, or WP_Error on guard/failure.
454 + */
455 + public function generate_vision_response( $prompt, $image, $max_tokens = null, $extra_system = array() ) {
456 + if ( empty( $image['data_uri'] ) ) {
457 + return new \WP_Error( 'ai_vision_no_image', __( 'No image data to send to the AI.', 'betterdocs' ) );
458 + }
218 459
219 - $api_endpoint = 'https://api.openai.com/v1/chat/completions';
460 + $factory = new ProviderFactory( $this->settings );
461 + $platform = $factory->active_platform();
462 + $model = $factory->active_model( $platform );
220 463
221 - $payload = AIHelper::build_openai_payload(
222 - $model,
223 - array(
464 + if ( ! $this->platform_supports_vision( $platform, $model ) ) {
465 + return new \WP_Error(
466 + 'ai_no_vision',
467 + sprintf(
468 + /* translators: 1: AI platform id, 2: model name. */
469 + __( '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' ),
470 + $platform,
471 + '' !== (string) $model ? $model : 'default'
472 + )
473 + );
474 + }
475 +
476 + try {
477 + $messages = array_merge(
478 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
479 + $this->normalize_extra_system( $extra_system ),
224 480 array(
225 - 'role' => 'system',
226 - 'content' => $this->get_system_prompt()
227 - ),
228 - array(
229 - 'role' => 'user',
230 - 'content' => $prompt
481 + array(
482 + 'role' => 'user',
483 + 'content' => array(
484 + array( 'type' => 'text', 'text' => (string) $prompt ),
485 + array( 'type' => 'image_url', 'image_url' => array( 'url' => (string) $image['data_uri'] ) ),
486 + ),
487 + ),
231 488 )
232 - ),
233 - $max_tokens,
234 - null,
235 - 'write_with_ai'
236 - );
489 + );
237 490
238 - $request_options = array(
239 - 'headers' => array(
240 - 'Content-Type' => 'application/json',
241 - 'Authorization' => 'Bearer ' . $api_key
242 - ),
243 - 'body' => wp_json_encode( $payload ),
244 - 'timeout' => 300
245 - );
491 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( $max_tokens ) );
246 492
247 - // GPT-5.5 reasoning can run well past the default limits; give PHP and
248 - // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
249 - if ( function_exists( 'set_time_limit' ) ) {
250 - set_time_limit( 300 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- long-running AI generation needs an extended limit; still bounded by server fpm/nginx timeouts.
493 + if ( is_wp_error( $result ) ) {
494 + return $result;
495 + }
496 +
497 + return $result['content'];
498 + } catch ( \Exception $error ) {
499 + return new \WP_Error( 'ai_vision_failed', 'Error: ' . $error->getMessage() );
251 500 }
501 + }
252 502
253 - $response = wp_remote_post( $api_endpoint, $request_options );
503 + /**
504 + * Whether the active platform + model can accept image input in the OpenAI
505 + * multimodal format. Deliberately conservative: only OpenAI vision model
506 + * families qualify, because Claude and Gemini require a different image
507 + * envelope this path does not build. gpt-3.5 (text-only) is excluded.
508 + *
509 + * @param string $platform
510 + * @param string $model
511 + * @return bool
512 + */
513 + protected function platform_supports_vision( $platform, $model ) {
514 + if ( 'openai' !== $platform ) {
515 + return false;
516 + }
254 517
255 - if ( is_wp_error( $response ) ) {
518 + $model = strtolower( (string) $model );
519 +
520 + if ( '' === $model || false !== strpos( $model, 'gpt-3.5' ) ) {
521 + return false;
522 + }
523 +
524 + foreach ( array( 'gpt-4o', 'gpt-4.1', 'gpt-4-turbo', 'gpt-4-vision', 'chatgpt-4o', 'gpt-5', 'o1', 'o3', 'o4' ) as $family ) {
525 + if ( false !== strpos( $model, $family ) ) {
526 + return true;
527 + }
528 + }
529 +
530 + return false;
531 + }
532 +
533 + public function get_outline_system_prompt() {
534 + $prompt = <<<'PROMPT'
535 +You are a Senior Technical Writer. Produce a documentation OUTLINE only — not the full article.
536 +
537 +Return ONLY a JSON array (no prose, no markdown, no code fences). Each element is an object:
538 + { "level": "h2" | "h3", "text": "Section heading" }
539 +
540 +Rules:
541 +- 5 to 9 top-level "h2" sections that comprehensively cover the topic.
542 +- Add "h3" sub-sections only where they genuinely help; keep each one directly after its parent h2.
543 +- Headings are concise, descriptive, and free of numbering.
544 +- Do not include an h1/title and do not include any text outside the JSON array.
545 +PROMPT;
546 +
547 + return apply_filters( 'betterdocs_write_with_ai_outline_system_prompt', $prompt );
548 + }
549 +
550 + /**
551 + * Generate a documentation outline as a structured array of sections.
552 + *
553 + * @param string $user_prompt The composed prompt (title/keywords/instructions).
554 + * @return array { success:bool, outline?:array<int,array{level:string,text:string}>, error?:string }
555 + */
556 + public function generate_outline_response( $user_prompt, $extra_system = array() ) {
557 + $result = $this->generate_text( $user_prompt, $this->get_outline_system_prompt(), $extra_system );
558 +
559 + if ( empty( $result['success'] ) ) {
256 560 return array(
257 561 'success' => false,
258 - 'error' => $response->get_error_message(),
259 - 'model' => $model
562 + 'error' => isset( $result['error'] ) ? $result['error'] : 'AI error',
260 563 );
261 564 }
262 565
263 - $body = wp_remote_retrieve_body( $response );
264 - $data = json_decode( $body, true );
566 + $outline = $this->parse_outline( (string) $result['content'] );
265 567
266 - if ( ! empty( $data[ 'error' ] ) ) {
568 + if ( empty( $outline ) ) {
267 569 return array(
268 570 'success' => false,
269 - 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
270 - 'model' => $model,
271 - 'raw' => $data
571 + 'error' => 'The AI returned an outline we could not read. Please try again.',
272 572 );
273 573 }
274 574
275 - $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
276 - $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
277 -
278 575 return array(
279 576 'success' => true,
280 - 'content' => $content,
281 - 'model' => $model,
282 - 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
283 - 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
284 - 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
285 - 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
577 + 'outline' => $outline,
286 578 );
287 579 }
288 580
289 581 /**
290 - * Generic chat completion using the Write-with-AI model/token/key settings.
582 + * Parse a model outline response (ideally a JSON array) into a clean list of
583 + * { level, text } sections. Tolerates code fences and stray prose.
291 584 *
292 - * Shared by lightweight, plain-text generators (glossary definitions, FAQ answers)
293 - * that need the same dynamic model as Write with AI / AI Edit but a caller-supplied
294 - * system prompt instead of the doc-authoring HTML prompt. Returns the same structured
295 - * array shape as {@see self::generate_openai_response_ai_edit()}.
585 + * @param string $content
586 + * @return array<int,array{level:string,text:string}>
587 + */
588 + protected function parse_outline( $content ) {
589 + $content = trim( $content );
590 + $content = preg_replace( '/^```(?:json)?\s*/i', '', $content );
591 + $content = preg_replace( '/```\s*$/', '', $content );
592 +
593 + // Grab the first JSON array if the model wrapped it in prose.
594 + if ( preg_match( '/\[[\s\S]*\]/', $content, $m ) ) {
595 + $content = $m[0];
596 + }
597 +
598 + $decoded = json_decode( $content, true );
599 + if ( ! is_array( $decoded ) ) {
600 + return array();
601 + }
602 +
603 + $outline = array();
604 + foreach ( $decoded as $item ) {
605 + if ( ! is_array( $item ) || empty( $item['text'] ) ) {
606 + continue;
607 + }
608 + $level = isset( $item['level'] ) && 'h3' === strtolower( (string) $item['level'] ) ? 'h3' : 'h2';
609 + $text = sanitize_text_field( (string) $item['text'] );
610 + if ( '' === $text ) {
611 + continue;
612 + }
613 + $outline[] = array( 'level' => $level, 'text' => $text );
614 + }
615 +
616 + return $outline;
617 + }
618 +
619 + public function generate_openai_response_ai_edit( $prompt, $extra_system = array() ) {
620 + $factory = new ProviderFactory( $this->settings );
621 + $model = $factory->active_model();
622 +
623 + $messages = array_merge(
624 + array( array( 'role' => 'system', 'content' => $this->get_system_prompt() ) ),
625 + $this->normalize_extra_system( $extra_system ),
626 + array( array( 'role' => 'user', 'content' => $prompt ) )
627 + );
628 +
629 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
630 +
631 + if ( is_wp_error( $result ) ) {
632 + return array(
633 + 'success' => false,
634 + 'error' => $result->get_error_message(),
635 + 'model' => $model
636 + );
637 + }
638 +
639 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
640 +
641 + return array(
642 + 'success' => true,
643 + 'content' => $result['content'],
644 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
645 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
646 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
647 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
648 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
649 + );
650 + }
651 +
652 + /**
653 + * Generic chat completion using the active Write-with-AI platform/model/token
654 + * settings. Shared by lightweight, plain-text generators (glossary definitions,
655 + * FAQ answers, outlines) that need the same dynamic model as Write with AI /
656 + * AI Edit but a caller-supplied system prompt instead of the doc-authoring HTML
657 + * prompt. Routes through ProviderFactory so every provider is supported.
296 658 *
297 659 * @param string $user_prompt The user message.
298 660 * @param string $system_prompt Optional system message (omitted when empty).
661 + * @param array $extra_system Optional extra system messages.
299 662 * @return array { success:bool, content?:string, error?:string, model:string, *_tokens?:int }
300 663 */
301 - public function generate_text( $user_prompt, $system_prompt = '' ) {
302 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
303 - $max_tokens = $this->settings->get( 'ai_autowrite_max_token', 2500 );
304 - $model = $this->settings->get( 'write_with_ai_model', 'gpt-4o-mini' );
664 + public function generate_text( $user_prompt, $system_prompt = '', $extra_system = array() ) {
665 + $factory = new ProviderFactory( $this->settings );
666 + $model = $factory->active_model();
305 667
306 668 $messages = array();
307 669 if ( $system_prompt !== '' ) {
308 - $messages[] = array(
309 - 'role' => 'system',
310 - 'content' => $system_prompt
311 - );
670 + $messages[] = array( 'role' => 'system', 'content' => $system_prompt );
312 671 }
313 - $messages[] = array(
314 - 'role' => 'user',
315 - 'content' => $user_prompt
316 - );
317 -
318 - $api_endpoint = 'https://api.openai.com/v1/chat/completions';
319 -
320 - $payload = AIHelper::build_openai_payload( $model, $messages, $max_tokens, null, 'write_with_ai' );
321 -
322 - $request_options = array(
323 - 'headers' => array(
324 - 'Content-Type' => 'application/json',
325 - 'Authorization' => 'Bearer ' . $api_key
326 - ),
327 - 'body' => wp_json_encode( $payload ),
328 - 'timeout' => 300
329 - );
330 -
331 - // GPT-5.5 reasoning can run well past the default limits; give PHP and
332 - // the HTTP call room to finish (still subject to server php-fpm/nginx limits).
333 - if ( function_exists( 'set_time_limit' ) ) {
334 - set_time_limit( 300 );
672 + foreach ( $this->normalize_extra_system( $extra_system ) as $extra ) {
673 + $messages[] = $extra;
335 674 }
675 + $messages[] = array( 'role' => 'user', 'content' => $user_prompt );
336 676
337 - $response = wp_remote_post( $api_endpoint, $request_options );
677 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
338 678
339 - if ( is_wp_error( $response ) ) {
679 + if ( is_wp_error( $result ) ) {
340 680 return array(
341 681 'success' => false,
342 - 'error' => $response->get_error_message(),
682 + 'error' => $result->get_error_message(),
343 683 'model' => $model
344 684 );
345 685 }
346 686
347 - $body = wp_remote_retrieve_body( $response );
348 - $data = json_decode( $body, true );
687 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
349 688
350 - if ( ! empty( $data[ 'error' ] ) ) {
689 + return array(
690 + 'success' => true,
691 + 'content' => $result['content'],
692 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
693 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
694 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
695 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
696 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
697 + );
698 + }
699 +
700 + /**
701 + * Generate a chat completion with a caller-supplied system + user prompt.
702 + * Mirrors generate_openai_response_ai_edit() (same model/token floor/timeout
703 + * handling and return shape) but does NOT force the documentation-writer
704 + * system prompt, so callers such as the Docs AI Suite (taxonomy suggestions,
705 + * excerpts) can supply task-appropriate instructions. Routes through the active
706 + * provider via ProviderFactory.
707 + *
708 + * @param string $system_prompt System instruction for the model.
709 + * @param string $user_prompt User message / content payload.
710 + * @param float|null $temperature Optional sampling temperature (ignored for gpt-5*).
711 + * @return array{success:bool,content?:string,error?:string,model:string,...}
712 + */
713 + public function generate_openai_response_raw( $system_prompt, $user_prompt, $temperature = null ) {
714 + $factory = new ProviderFactory( $this->settings );
715 + $model = $factory->active_model();
716 +
717 + $messages = array(
718 + array( 'role' => 'system', 'content' => $system_prompt ),
719 + array( 'role' => 'user', 'content' => $user_prompt )
720 + );
721 +
722 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( null, $temperature ) );
723 +
724 + if ( is_wp_error( $result ) ) {
351 725 return array(
352 726 'success' => false,
353 - 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
354 - 'model' => $model,
355 - 'raw' => $data
727 + 'error' => $result->get_error_message(),
728 + 'model' => $model
356 729 );
357 730 }
358 731
359 - $content = isset( $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] ) ? $data[ 'choices' ][ 0 ][ 'message' ][ 'content' ] : '';
360 - $usage = isset( $data[ 'usage' ] ) && is_array( $data[ 'usage' ] ) ? $data[ 'usage' ] : array();
732 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
361 733
362 734 return array(
363 735 'success' => true,
364 - 'content' => $content,
365 - 'model' => $model,
366 - 'prompt_tokens' => isset( $usage[ 'prompt_tokens' ] ) ? (int) $usage[ 'prompt_tokens' ] : null,
367 - 'completion_tokens' => isset( $usage[ 'completion_tokens' ] ) ? (int) $usage[ 'completion_tokens' ] : null,
368 - 'total_tokens' => isset( $usage[ 'total_tokens' ] ) ? (int) $usage[ 'total_tokens' ] : null,
369 - 'finish_reason' => isset( $data[ 'choices' ][ 0 ][ 'finish_reason' ] ) ? $data[ 'choices' ][ 0 ][ 'finish_reason' ] : null
736 + 'content' => $result['content'],
737 + 'model' => isset( $result['model'] ) ? $result['model'] : $model,
738 + 'prompt_tokens' => isset( $usage['prompt_tokens'] ) ? $usage['prompt_tokens'] : null,
739 + 'completion_tokens' => isset( $usage['completion_tokens'] ) ? $usage['completion_tokens'] : null,
740 + 'total_tokens' => isset( $usage['total_tokens'] ) ? $usage['total_tokens'] : null,
741 + 'finish_reason' => isset( $result['finish_reason'] ) ? $result['finish_reason'] : null
370 742 );
371 743 }
372 744
373 745 public function generate_openai_content_callback() {
@@ -399,1101 +771,5 @@
399 771 // Send the generated content as the AJAX response
400 772 wp_send_json_success( $generated_content );
401 773 wp_die();
402 774 }
403 -
404 - public function ai_autowrite_button() {
405 -
406 - ?>
407 - <script>
408 - var docsTitle;
409 -
410 - function responseMessage(message) {
411 - return `<div class="warning-message">
412 - <span class="dashicons dashicons-warning"></span>
413 - <div class="footer-message">
414 - ${message}
415 - </div>
416 - </div>`;
417 - }
418 -
419 - function generateArticle(e) {
420 -
421 -
422 - const title = document.getElementById('betterdocs-ai-title').value;
423 - const keywords = document.getElementById('betterdocs-ai-keyword').value;
424 - // const sectionOptions = document.getElementById('bd-autowrtite-content-section');
425 - // const numOfSections = sectionOptions.options[sectionOptions.selectedIndex].value;
426 -
427 - // const paragraphOptions = document.getElementById('bd-autowrtite-content-paragraph');
428 - // const numOfParagraphs = paragraphOptions.options[paragraphOptions.selectedIndex].value;
429 -
430 - const contentTextArea = document.getElementById('betterdocs-ai-content');
431 - const docGenerateBtnTxt = document.querySelector('.generate-btn span');
432 -
433 - // Add nonce value to the data being sent
434 - const nonce = document.querySelector('input[name="ai_nonce"]').value;
435 - const isOverwrite = document.getElementById('betterdocs-ai-checkbox').checked;
436 - const generateDocLabel = "<?php echo esc_html__( 'Generate Doc', 'betterdocs' ); ?>";
437 - const reGenerateDocLabel = "<?php echo esc_html__( 'Regenerate Doc', 'betterdocs' ); ?>";
438 -
439 -
440 - // 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.`;
441 -
442 -
443 - // Check if the title is not empty
444 - if (title.trim() !== '' && keywords.trim() !== '') {
445 - e.preventDefault();
446 -
447 - if (!jQuery('#betterdocs-ai-error-message').parent().hasClass('hidden')) {
448 - jQuery('#betterdocs-ai-error-message').parent().addClass('hidden');
449 - }
450 -
451 - docGenerateBtnTxt.innerHTML = "<?php echo esc_html__( 'Generating...', 'betterdocs' ); ?>";
452 - jQuery('.generate-btn').prop('disabled', true);
453 -
454 - jQuery.post({
455 - url: ajaxurl, // Make sure ajaxurl is defined in your script
456 - type: 'POST',
457 - data: {
458 - action: 'generate_openai_content',
459 - prompt: document.querySelector('#betterdocs-ai-content').value,
460 - // numOfSections: numOfSections,
461 - // numOfParagraphs: numOfParagraphs,
462 - keywords: keywords,
463 - post_id: (document.getElementById('post_ID') ? document.getElementById('post_ID').value : 0),
464 - ai_nonce: nonce, // Pass the nonce value
465 - },
466 - success: function(response) {
467 - // Update the content in the textarea
468 -
469 - if (response.success && (typeof response.data === 'string')) {
470 - docGenerateBtnTxt.innerHTML = "<?php echo esc_html__( 'Generated', 'betterdocs' ); ?>";
471 - setTimeout(() => {
472 - insertContentToEditor(title, response.data, isOverwrite, keywords);
473 - docGenerateBtnTxt.innerHTML = reGenerateDocLabel;
474 - jQuery('.generate-btn').removeAttr('disabled');
475 - }, 2000);
476 - } else {
477 - // alert(response.data.message);
478 - jQuery('#betterdocs-ai-error-message').parent().removeClass('hidden');
479 - jQuery('#betterdocs-ai-error-message').html(responseMessage(response.data.message));
480 - docGenerateBtnTxt.innerHTML = generateDocLabel;
481 - jQuery('.generate-btn').removeAttr('disabled');
482 -
483 - }
484 - },
485 - error: function(jqXHR, textStatus, errorThrown) {
486 - console.error(jqXHR, textStatus, errorThrown);
487 -
488 - // Reset the UI so a slow/failed request never leaves a permanent "Generating...".
489 - var timedOut = ( textStatus === 'timeout' ) || ( jqXHR && jqXHR.status === 504 ) || ( jqXHR && jqXHR.status === 0 );
490 - var errMessage = timedOut
491 - ? "<?php echo esc_html__( 'The request timed out before a response came back. The AI may still have generated content on the server — try again, or pick a faster model / lower the token limit.', 'betterdocs' ); ?>"
492 - : "<?php echo esc_html__( 'Something went wrong while generating. Please try again.', 'betterdocs' ); ?>";
493 -
494 - jQuery('#betterdocs-ai-error-message').parent().removeClass('hidden');
495 - jQuery('#betterdocs-ai-error-message').html(responseMessage(errMessage));
496 - docGenerateBtnTxt.innerHTML = generateDocLabel;
497 - jQuery('.generate-btn').removeAttr('disabled');
498 - },
499 - });
500 -
501 - }
502 - }
503 -
504 - // Function to update the textarea
505 - function updateTextarea() {
506 - const title = document.getElementById('betterdocs-ai-title').value;
507 - const keywords = document.getElementById('betterdocs-ai-keyword').value;
508 - const sectionOptions = document.getElementById('bd-autowrtite-content-section');
509 - let language = 'English';
510 -
511 - if (language === '') {
512 - language = 'English';
513 - }
514 -
515 - let keywordsText = keywords !== '' ? ` and incorporate the keywords: ${keywords}` : '';
516 -
517 - const contentTextArea = document.getElementById('betterdocs-ai-content');
518 -
519 - if (!jQuery('#betterdocs-ai-error-message').parent().hasClass('hidden')) {
520 - jQuery('#betterdocs-ai-error-message').parent().addClass('hidden');
521 - }
522 -
523 - contentTextArea.value = `Write documentation for '${title}'. Cover these topics in depth: ${keywords}. Use headings, paragraphs, lists, links, code blocks, and tables wherever they help the reader. Follow the output-format rules in the system instructions.`;
524 - }
525 -
526 - function convertMarkdownToHTML(markdownContent) {
527 - // Simple Markdown to HTML conversion (you may need to enhance this based on your needs)
528 - const htmlContent = markdownContent
529 - .replace(/^# (.+)$/gm, '<h1>$1</h1>')
530 - .replace(/^## (.+)$/gm, '<h2>$1</h2>')
531 - .replace(/^### (.+)$/gm, '<h3>$1</h3>')
532 - .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
533 - .replace(/\*(.+?)\*/g, '<em>$1</em>');
534 -
535 - return htmlContent;
536 - }
537 -
538 - function replaceHeadingTitle(content, title, overviewTitle) {
539 - let regex = new RegExp(title, 'g');
540 - let updateContent = content.replace(regex, overviewTitle);
541 - return updateContent;
542 - }
543 -
544 - function removeFirstHeading(htmlString) {
545 - var newDiv = document.createElement('div');
546 - newDiv.innerHTML = htmlString;
547 - var firstHeading = newDiv.querySelector('h1');
548 - if (firstHeading) {
549 - firstHeading.parentNode.removeChild(firstHeading);
550 - }
551 - return newDiv.innerHTML;
552 - }
553 -
554 -
555 - function escapeHtml(str) {
556 - return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
557 - }
558 -
559 - function escapeRegex(str) {
560 - return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
561 - }
562 -
563 - function stripCodeFences(htmlString) {
564 - if (!htmlString) {
565 - return '';
566 - }
567 - return htmlString
568 - .replace(/^\s*```(?:html|HTML)?\s*\n?/, '')
569 - .replace(/\n?\s*```\s*$/, '');
570 - }
571 -
572 - function wrapwithHeighlight(inputString, keywords) {
573 - if (!inputString) {
574 - return '';
575 - }
576 -
577 - const container = document.createElement('div');
578 - container.innerHTML = inputString;
579 -
580 - // Wrap each heading's visible text with the highlight span (skip if already present)
581 - container.querySelectorAll('h1, h2, h3, h4, h5, h6').forEach(function (heading) {
582 - if (heading.querySelector('.highlight')) {
583 - return;
584 - }
585 - const text = heading.textContent;
586 - if (!text || !text.trim()) {
587 - return;
588 - }
589 - heading.innerHTML = '<span class="highlight">' + escapeHtml(text) + '</span>';
590 - });
591 -
592 - // Wrap keywords only inside text nodes — never inside attributes, code, or existing highlights
593 - const keywordList = (keywords || '')
594 - .split(',')
595 - .map(function (k) { return k.trim(); })
596 - .filter(Boolean);
597 -
598 - if (keywordList.length) {
599 - const pattern = new RegExp('\\b(' + keywordList.map(escapeRegex).join('|') + ')\\b', 'gi');
600 - const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
601 - acceptNode: function (node) {
602 - let parent = node.parentNode;
603 - while (parent && parent !== container) {
604 - if (parent.nodeType === 1) {
605 - const tag = parent.tagName.toLowerCase();
606 - if (tag === 'code' || tag === 'pre') {
607 - return NodeFilter.FILTER_REJECT;
608 - }
609 - if (parent.classList && parent.classList.contains('highlight')) {
610 - return NodeFilter.FILTER_REJECT;
611 - }
612 - }
613 - parent = parent.parentNode;
614 - }
615 - return NodeFilter.FILTER_ACCEPT;
616 - }
617 - });
618 -
619 - const textNodes = [];
620 - let current;
621 - while ((current = walker.nextNode())) {
622 - textNodes.push(current);
623 - }
624 -
625 - textNodes.forEach(function (textNode) {
626 - pattern.lastIndex = 0;
627 - if (!pattern.test(textNode.nodeValue)) {
628 - return;
629 - }
630 - pattern.lastIndex = 0;
631 - const fragmentHost = document.createElement('span');
632 - fragmentHost.innerHTML = escapeHtml(textNode.nodeValue).replace(pattern, '<span class="highlight">$1</span>');
633 - const parent = textNode.parentNode;
634 - while (fragmentHost.firstChild) {
635 - parent.insertBefore(fragmentHost.firstChild, textNode);
636 - }
637 - parent.removeChild(textNode);
638 - });
639 - }
640 -
641 - return container.innerHTML;
642 - }
643 -
644 - function getBodyContent(htmlString) {
645 - if (!htmlString) {
646 - return '';
647 - }
648 - const cleaned = stripCodeFences(htmlString);
649 - const match = cleaned.match(/<body[^>]*>([\s\S]*?)<\/body>/i);
650 - if (match) {
651 - return match[1].replace(/<p>\s+/g, '<p>');
652 - }
653 - return cleaned;
654 - }
655 -
656 -
657 -
658 - function insertContentToEditor(title, htmlContent, isOverwrite, keywords) {
659 -
660 - const {
661 - dispatch,
662 - select
663 - } = wp.data;
664 -
665 - htmlContent = getBodyContent(htmlContent);
666 - htmlContent = removeFirstHeading(htmlContent);
667 - htmlContent = wrapwithHeighlight(htmlContent, keywords);
668 - const isPostContent = `<?php echo isset( $_GET['post'] ) ? esc_html( get_the_content( absint( wp_unslash( $_GET['post'] ) ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post id from URL. ?>`;
669 -
670 - const blocks = wp.blocks.rawHandler({
671 - HTML: htmlContent
672 - });
673 -
674 - dispatch('core/editor').editPost({
675 - title: title,
676 - });
677 -
678 - const selectedBlockClientId = select('core/block-editor').getSelectedBlockClientId();
679 -
680 - const allBlocks = select('core/block-editor').getBlocks();
681 -
682 - if (isOverwrite || isPostContent === '') {
683 -
684 - allBlocks.forEach((block) => {
685 - dispatch('core/block-editor').removeBlock(block.clientId);
686 - });
687 - dispatch('core/block-editor').insertBlocks(blocks);
688 -
689 - } else if (selectedBlockClientId) {
690 - const selectedIndex = select('core/block-editor').getBlockIndex(selectedBlockClientId);
691 - dispatch('core/block-editor').insertBlocks(blocks, selectedIndex + 1);
692 - } else {
693 - dispatch('core/block-editor').insertBlocks(blocks);
694 - }
695 -
696 - closeWriteWithAIForm('afterInsertContent');
697 - }
698 -
699 - function closeWriteWithAIForm(after) {
700 - jQuery('.betterdocs-ai-autowrite-form-container').addClass('hidden');
701 - jQuery('#betterdocs-ai-autowrite-form-container-overlay').addClass('hidden');
702 - jQuery('.betterdocs-ai-button').addClass('regenerate-btn');
703 - }
704 -
705 -
706 - document.addEventListener('DOMContentLoaded', function() {
707 -
708 - // Subscribe to changes in the editor state
709 - let previousDocsTitle = '';
710 -
711 - wp.data.subscribe(() => {
712 - // Get the updated post title
713 - // const docsTitle = wp.data.select('core/editor').getEditedPostAttribute('title');
714 -
715 - const docsTitle = wp.data.select('core/editor').getEditedPostAttribute('title');
716 -
717 - // Check if the title has changed
718 - if (docsTitle !== previousDocsTitle) {
719 - let currentKeywords = jQuery('#betterdocs-ai-keyword').val();
720 - if (!currentKeywords) {
721 - currentKeywords = '{Documentation Keywords}';
722 - }
723 - const currentPrompt = `Write documentation for '${docsTitle?docsTitle:'{Documentation title}'}'. Cover these topics in depth: ${currentKeywords}. Use headings, paragraphs, lists, links, code blocks, and tables wherever they help the reader. Follow the output-format rules in the system instructions.`;
724 - jQuery('#betterdocs-ai-content').val(currentPrompt);
725 -
726 - jQuery('#betterdocs-ai-title').val(docsTitle);
727 -
728 - previousDocsTitle = docsTitle;
729 - }
730 -
731 -
732 - });
733 - });
734 -
735 -
736 - // This example assumes that this code is executed when your script is loaded.
737 - // You may want to place it within the appropriate context in your application.
738 -
739 -
740 - function writeWithAIForm() {
741 -
742 - let title = `<?php echo isset( $_GET['post'] ) ? esc_html( get_the_title( absint( wp_unslash( $_GET['post'] ) ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post id from URL. ?>`;
743 - if (docsTitle) {
744 - title = docsTitle;
745 - }
746 -
747 - const promtTitle = `<?php echo isset( $_GET['post'] ) ? esc_html( get_the_title( absint( wp_unslash( $_GET['post'] ) ) ) ) : '{Documentation Title}'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post id from URL. ?>`;
748 - const titlePlaceholder = "<?php echo esc_attr__( 'Enter a descriptive title for your documentation.', 'betterdocs' ); ?>";
749 - const keywords = "<?php echo esc_attr( '{Documentation Keywords}' ); ?>";
750 - const keywordsPlaceholder = "<?php echo esc_attr__( 'Add keywords to generate precise & relevant documentation (comma-separated).', 'betterdocs' ); ?>";
751 - const language = "<?php echo esc_attr( 'English' ); ?>";
752 - const titleLabel = "<?php echo esc_html__( 'Documentation Title:', 'betterdocs' ); ?>";
753 - const keywordLabel = "<?php echo esc_html__( 'Keywords:', 'betterdocs' ); ?>";
754 - const secLabel = "<?php echo esc_html__( '# of Sections:', 'betterdocs' ); ?>";
755 - const paraLabel = "<?php echo esc_html__( '# of Paragraph Per Section: ', 'betterdocs' ); ?>";
756 - const langLabel = "<?php echo esc_html__( 'Language:', 'betterdocs' ); ?>";
757 - const promtLabel = "<?php echo esc_html__( 'Prompt:', 'betterdocs' ); ?>";
758 - const promtBtnLabel = "<?php echo esc_html__( 'Generate Prompt', 'betterdocs' ); ?>";
759 - let promtArticleLabel = "<?php echo esc_html__( 'Generate Doc', 'betterdocs' ); ?>";
760 - const checkboxLabel = "<?php echo esc_html__( 'Overwrite your existing Doc:', 'betterdocs' ); ?>";
761 - const nonce = "<?php echo esc_attr( wp_create_nonce( 'generate_openai_content_nonce' ) ); ?>";
762 -
763 - <?php
764 - $is_valid = $this->get_api_key();
765 - $disable_field = 'disabled-input-field';
766 - if ( $this->get_api_key() ) {
767 - $disable_field = '';
768 - }
769 - ?>
770 -
771 - let hiddenClass = 'hidden';
772 - let newDocPageAtt = 'data-new-doc-page="true"';
773 - const isPostContent = `<?php echo isset( $_GET['post'] ) ? esc_html( get_the_content( absint( wp_unslash( $_GET['post'] ) ) ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only post id from URL. ?>`;
774 -
775 - if (isPostContent !== '') {
776 - hiddenClass = '';
777 - newDocPageAtt = '';
778 - }
779 -
780 -
781 - // 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.`;
782 -
783 - // 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.`;
784 -
785 - const prompt = `Write documentation for '${promtTitle}'. Cover these topics in depth: ${keywords}. Use headings, paragraphs, lists, links, code blocks, and tables wherever they help the reader. Follow the output-format rules in the system instructions.`;
786 -
787 - const aiIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="-1.5 -1.5 23 23" fill="none"><path d="m9.895 18.861-4.286.371.371-4.286 8.915-8.857a1.429 1.429 0 0 1 2.043 0l1.814 1.829a1.431 1.431 0 0 1 0 2.029l-8.857 8.914ZM1.204 5.674c-.501-.088-.501-.807 0-.894a4.537 4.537 0 0 0 3.654-3.5l.03-.139c.109-.494.814-.499.929-.003l.036.16a4.561 4.561 0 0 0 3.663 3.48c.504.086.504.81 0 .899a4.561 4.561 0 0 0-3.664 3.479l-.037.161c-.112.494-.818.491-.926-.004l-.029-.139a4.537 4.537 0 0 0-3.658-3.5h.003Z" stroke="#fff" stroke-width="1.429" stroke-linecap="round" stroke-linejoin="round"/></svg>`;
788 -
789 -
790 -
791 - return `
792 - <div class="betterdocs-ai-autowrite-form-container hidden" ${newDocPageAtt}>
793 - <div class="betterdocs-ai-autowrite-form-content">
794 - <div class="betterdocs-ai-autowrite-top-part">
795 - <div class="autowrite-heading">
796 - <span class="autowrite-icon" style="--bd-icon-tint: rgb(127, 86, 217); --bd-icon-tint-rgb: 127, 86, 217; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; flex-shrink:0; border-radius:9px; color:rgb(127, 86, 217); background:rgba(127, 86, 217, 0.2);">
797 - <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="-1.5 -1.5 23 23"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.429" d="m9.895 18.861-4.286.371.371-4.286 8.915-8.857a1.43 1.43 0 0 1 2.043 0l1.814 1.829a1.43 1.43 0 0 1 0 2.029zM1.204 5.674c-.501-.088-.501-.807 0-.894a4.54 4.54 0 0 0 3.654-3.5l.03-.139c.109-.494.814-.499.929-.003l.036.16a4.56 4.56 0 0 0 3.663 3.48c.504.086.504.81 0 .899a4.56 4.56 0 0 0-3.664 3.479l-.037.161c-.112.494-.818.491-.926-.004l-.029-.139a4.54 4.54 0 0 0-3.658-3.5h.003Z"></path></svg>
798 - </span>
799 - <h1 style="margin:0; font-family:'IBM Plex Sans',sans-serif; font-size:18px; font-weight:600; line-height:1.3; color:#101828;"><?php echo esc_html__( 'Write Documentation with BetterDocs AI', 'betterdocs' ); ?></h1>
800 -
801 - </div>
802 - <div class="autowrite-subheadding">
803 - <p style="margin:8px 0 0; font-family:'IBM Plex Sans',sans-serif; font-size:14px; font-weight:400; line-height:1.4; color:#667085;"><?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>
804 - </div>
805 -
806 - <?php if ( empty( $this->get_api_key() ) ): ?>
807 - <div id="betterdocs-ai-message">
808 - <div class="warning-message">
809 - <span class="dashicons dashicons-warning"></span>
810 - <div>
811 - <?php echo wp_kses_post( 'Please Insert your <a target="_blank" href="' . esc_url( admin_url( 'admin.php?page=betterdocs-settings#betterdocs-ai' ) ) . '">OpenAI API Key</a> to use this Write with AI feature.', 'betterdocs' ); ?>
812 - </div>
813 - </div>
814 -
815 - </div>
816 - <?php endif; ?>
817 -
818 - <div class="form-group hidden">
819 - <div id="betterdocs-ai-error-message"></div>
820 - </div>
821 -
822 - </div>
823 - <form id="betterdocs-ai-form" class="<?php echo esc_attr( $disable_field ); ?>">
824 - <div class="form-inner-content">
825 - <div class="form-group">
826 - <label for="betterdocs-ai-title">${titleLabel}</label>
827 - <input type="text" placeholder="${titlePlaceholder}" id="betterdocs-ai-title" name="betterdocs-ai-title" required value="${title}">
828 - </div>
829 -
830 - <div class="form-group">
831 - <label for="betterdocs-ai-keyword">${keywordLabel}</label>
832 - <input type="text" placeholder="${keywordsPlaceholder}" id="betterdocs-ai-keyword" name="betterdocs-ai-keyword" required>
833 - </div>
834 -
835 - <div class="form-group prompt-field">
836 - <label for="betterdocs-ai-content">${promtLabel}</label>
837 - <textarea id="betterdocs-ai-content" name="betterdocs-ai-content" rows="6" required>${prompt}</textarea>
838 - <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>
839 - </div>
840 - <div class="betterdocs-ai-checkbox-field ${hiddenClass}">
841 - <div class="form-group">
842 - <div class="checkbox-field-label">
843 - <label for="betterdocs-ai-checkbox">${checkboxLabel}</label>
844 - <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>
845 - </div>
846 -
847 - <div class="checkbox-input-field">
848 - <input type="checkbox" id="betterdocs-ai-checkbox" name="betterdocs-ai-checkbox" data-overwrite="false">
849 - <label for="betterdocs-ai-checkbox" class="toggle-label"></label>
850 - </div>
851 - </div>
852 - </div>
853 -
854 - <input type="hidden" name="ai_nonce" value="${nonce}" />
855 - </div>
856 - <div class="generate-button-container">
857 - <button type="submit" class="generate-btn" onclick="generateArticle(event)">
858 - ${aiIcon} <span>${promtArticleLabel}</span>
859 - </button>
860 - </div>
861 - </form>
862 - </div>
863 -
864 - <div class="bd-close-button" onclick="closeWriteWithAIForm('afterClosedClicked')"><svg width="15" height="14" viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M1.69687 14L0.296875 12.6L5.89687 7L0.296875 1.4L1.69687 0L7.29688 5.6L12.8969 0L14.2969 1.4L8.69688 7L14.2969 12.6L12.8969 14L7.29688 8.4L1.69687 14Z" fill="currentColor"/></svg></div>
865 - </div>
866 -
867 - <div id="betterdocs-ai-autowrite-form-container-overlay" class="hidden"></div>
868 - `;
869 - }
870 -
871 - jQuery(document).on('click', '.regenerate-btn', function() {
872 - jQuery('.betterdocs-ai-autowrite-form-container').removeClass('hidden');
873 - jQuery('#betterdocs-ai-autowrite-form-container-overlay').removeClass('hidden');
874 - });
875 -
876 - jQuery(document).ready(function($) {
877 - // Check if we are in the Edit Post screen
878 -
879 - if ($('.block-editor-page').length > 0) {
880 -
881 - const bdAIButton = $(`<div class="betterdocs-ai-button-container">
882 - <button class="betterdocs-ai-button">
883 - <img src="<?php echo esc_url( BETTERDOCS_ABSURL . 'assets/admin/images/betterdocs-icon-white.png' ); ?>" alt="<?php echo esc_attr__( 'betterdocs icon', 'betterdocs' ); ?>" />
884 - <span><?php echo esc_html__( 'Write with AI', 'betterdocs' ); ?></span>
885 - </button>
886 - </div>`);
887 -
888 -
889 - const closeBtn = $('bd-close-button');
890 -
891 - const formHtml = writeWithAIForm();
892 - $(formHtml).addClass('hidden');
893 -
894 - $(document).on('click', '.betterdocs-ai-button', function() {
895 - $('.betterdocs-ai-autowrite-form-container').removeClass('hidden');
896 - $('#betterdocs-ai-autowrite-form-container-overlay').removeClass('hidden');
897 - });
898 -
899 - // $(document).on('click', '.betterdocs-ai-button', function() {
900 - if ($('.betterdocs-ai-autowrite-form-container').length < 1) {
901 - $('body').append(formHtml);
902 -
903 - // Add event listeners to input elements
904 - document.getElementById('betterdocs-ai-title').addEventListener('input', updateTextarea);
905 - document.getElementById('betterdocs-ai-keyword').addEventListener('input', updateTextarea);
906 - // document.getElementById('bd-autowrtite-content-section').addEventListener('change', updateTextarea);
907 - // document.getElementById('bd-autowrtite-content-paragraph').addEventListener('change', updateTextarea);
908 - // document.getElementById('betterdocs-ai-language').addEventListener('input', updateTextarea);
909 - }
910 - // });
911 - const btn = document.createElement('button');
912 - wp.data.subscribe(function() {
913 - setTimeout(() => {
914 - if ($('.edit-post-header__settings').length > 0) {
915 - $('.edit-post-header__settings').prepend(bdAIButton);
916 - } else if ($('.editor-header__settings').length > 0) {
917 - $('.editor-header__settings').prepend(bdAIButton);
918 - }
919 -
920 - }, 1);
921 - });
922 - }
923 -
924 - $(document).on('click', '#betterdocs-ai-autowrite-form-container-overlay', function() {
925 - closeWriteWithAIForm();
926 - });
927 - });
928 - </script>
929 -
930 - <style>
931 - .hidden {
932 - display: none !important;
933 - }
934 -
935 - .disabled-input-field input,
936 - .disabled-input-field textarea,
937 - .disabled-input-field button,
938 - .disabled-input-field label {
939 - pointer-events: none;
940 - opacity: 0.6;
941 - }
942 -
943 - .disabled-input-field label {
944 - opacity: 1;
945 - }
946 -
947 - .betterdocs-ai-button-container {
948 - margin-left: 10px;
949 - white-space: nowrap;
950 - max-width: 145px;
951 - }
952 -
953 - .autowrite-icon {
954 - display: flex;
955 - align-items: center;
956 - width: 32px;
957 - height: 32px;
958 - flex-shrink: 0;
959 - /* Mint chip + green sparkle — matches the FAQ/Glossary modal
960 - header icons (rgba(#00B884, 0.12)). */
961 - background: rgba(0, 184, 132, 0.12);
962 - justify-content: center;
963 - border-radius: 9px;
964 - }
965 -
966 - .autowrite-icon svg {
967 - width: 17px;
968 - height: 17px;
969 - }
970 -
971 - .autowrite-heading {
972 - display: flex;
973 - gap: 10px;
974 - align-items: center;
975 - /* Keep the title clear of the in-header close button. */
976 - padding-right: 64px;
977 - }
978 -
979 - .autowrite-heading h1 {
980 - color: #101828;
981 - font-family: 'IBM Plex Sans', sans-serif;
982 - font-size: 18px;
983 - font-style: normal;
984 - font-weight: 600;
985 - line-height: 1.3;
986 - margin: 0;
987 - }
988 -
989 - .autowrite-heading p,
990 - form#betterdocs-ai-form p {
991 - margin: 0;
992 - margin-bottom: 24px;
993 - }
994 -
995 - .autowrite-subheadding p {
996 - font-family: 'IBM Plex Sans', sans-serif;
997 - font-size: 13px;
998 - color: #667085;
999 - }
1000 -
1001 - .prompt-field .input-description {
1002 - margin: 0 !important;
1003 - }
1004 -
1005 - .autowrite-heading p {
1006 - color: #475467;
1007 - font-family: 'IBM Plex Sans', sans-serif;
1008 - font-size: 16px;
1009 - font-style: normal;
1010 - font-weight: 400;
1011 - }
1012 -
1013 - .betterdocs-ai-button {
1014 - background: #00B884;
1015 - border: 1px solid transparent;
1016 - color: #fff;
1017 - box-shadow: none;
1018 - font-size: 12px;
1019 - margin-right: 8px;
1020 - padding: 0px 15px;
1021 - cursor: pointer;
1022 - border-radius: 3px;
1023 - height: 38px;
1024 - display: flex;
1025 - align-items: center;
1026 - justify-content: space-between;
1027 - gap: 10px;
1028 - width: 135px;
1029 - }
1030 -
1031 - .betterdocs-ai-button img {
1032 - width: 20px;
1033 - height: 20px;
1034 - }
1035 -
1036 - .betterdocs-ai-autowrite-form-container {
1037 - position: fixed;
1038 - top: 50%;
1039 - left: 50%;
1040 - transform: translate(-50%, -50%);
1041 - z-index: 100001;
1042 - width: 650px;
1043 - margin: 0 auto;
1044 - background-color: #fff;
1045 - border-radius: 20px;
1046 - font-family: 'IBM Plex Sans', sans-serif;
1047 - /* max-height: 600px; */
1048 - max-height: 750px;
1049 - max-width: 100%;
1050 - }
1051 -
1052 - .betterdocs-ai-autowrite-form-content {
1053 - background-color: #fff;
1054 - padding: 0;
1055 - border-radius: 20px;
1056 - overflow: auto;
1057 - /* max-height: 700px; */
1058 - height: 100%;
1059 -
1060 - }
1061 -
1062 - .betterdocs-ai-autowrite-top-part {
1063 - /* Soft-gray header bar — matches the FAQ/Glossary/Edit-with-AI modals. */
1064 - background: #f9fafb;
1065 - border-bottom: 1px solid #f2f4f7;
1066 - border-radius: 20px 20px 0 0;
1067 - padding: 24px 40px 20px;
1068 - }
1069 -
1070 - #betterdocs-ai-form {
1071 - display: flex;
1072 - flex-direction: column;
1073 - /* height: 100%; */
1074 - overflow: hidden;
1075 - }
1076 -
1077 - .form-inner-content {
1078 - overflow: auto;
1079 - height: 100%;
1080 - max-height: 435px;
1081 - /* max-height: 330px; */
1082 - padding: 0 40px;
1083 - }
1084 -
1085 - .form-inner-content>.form-group {
1086 - margin-top: 20px;
1087 - }
1088 -
1089 -
1090 - #betterdocs-ai-autowrite-form-container-overlay {
1091 - position: fixed;
1092 - top: 0;
1093 - right: 0;
1094 - bottom: 0;
1095 - left: 0;
1096 - background-color: rgba(0, 0, 0, .7);
1097 - z-index: 100000;
1098 - }
1099 -
1100 -
1101 -
1102 - #betterdocs-ai-form {
1103 - display: flex;
1104 - flex-direction: column;
1105 - }
1106 -
1107 - #betterdocs-ai-form .form-group {
1108 - margin-bottom: 24px;
1109 - }
1110 -
1111 - #betterdocs-ai-form .bd-aiform-flex {
1112 - display: flex;
1113 - justify-content: space-between;
1114 - }
1115 -
1116 - #betterdocs-ai-form label {
1117 - font-size: 14px;
1118 - font-weight: 500;
1119 - color: #667085;
1120 - margin-bottom: 5px;
1121 - display: block;
1122 - line-height: 20px;
1123 - }
1124 -
1125 - select#bd-autowrtite-content-section,
1126 - #bd-autowrtite-content-paragraph,
1127 - #betterdocs-ai-language {
1128 - width: 150px !important;
1129 - height: 40px;
1130 - border: 1px solid #f2f4f7;
1131 - background: #f2f4f7;
1132 - color: #101828;
1133 - font-size: 14px;
1134 - border-radius: 5px;
1135 - padding: 5px 12px;
1136 - outline: none;
1137 - box-shadow: none;
1138 - }
1139 -
1140 - #betterdocs-ai-form input[type="text"],
1141 - #betterdocs-ai-form textarea {
1142 - width: 100%;
1143 - box-sizing: border-box;
1144 - border: 1px solid #f2f4f7;
1145 - background: #fff;
1146 - border-radius: 5px;
1147 - color: #101828;
1148 - font-size: 14px;
1149 - font-weight: 400;
1150 - outline: none;
1151 - box-shadow: none;
1152 - }
1153 -
1154 - #betterdocs-ai-form input[type="text"] {
1155 - height: 40px;
1156 - padding: 5px 15px;
1157 - }
1158 -
1159 - #betterdocs-ai-form textarea {
1160 - padding: 10px 15px;
1161 - min-height: 100px;
1162 - }
1163 -
1164 - /* Override autofill text color */
1165 - #betterdocs-ai-form input:-webkit-autofill {
1166 - -webkit-text-fill-color: #101828 !important;
1167 - }
1168 -
1169 - #betterdocs-ai-form input::placeholder,
1170 - #betterdocs-ai-form textarea::placeholder {
1171 - color: #667085;
1172 - }
1173 -
1174 - #betterdocs-ai-form input:focus,
1175 - #betterdocs-ai-form textarea:focus {
1176 - border-color: #00b884;
1177 - box-shadow: 0 0 0 3px rgba(0, 184, 132, 0.15);
1178 - outline: none;
1179 - }
1180 -
1181 -
1182 - /* Footer bar — soft gray surface; Cancel pinned left, Generate right. */
1183 - .generate-button-container {
1184 - position: sticky;
1185 - bottom: 0px;
1186 - width: 100%;
1187 - box-sizing: border-box;
1188 - margin-left: 0;
1189 - z-index: 999999 !important;
1190 - padding: 16px 22px;
1191 - display: flex;
1192 - align-items: center;
1193 - justify-content: flex-end;
1194 - border-top: 1px solid #f2f4f7;
1195 - background: #f9fafb;
1196 - border-bottom-right-radius: 20px;
1197 - border-bottom-left-radius: 20px;
1198 - }
1199 -
1200 - .generate-button-container button {
1201 - display: flex;
1202 - gap: 8px;
1203 - align-items: center;
1204 - justify-content: center;
1205 - opacity: 1 !important;
1206 - }
1207 -
1208 - /* Cancel — white pill with neutral border. Stays clickable even when
1209 - the form is disabled (missing API key). */
1210 - .generate-button-container .bd-ai-cancel-btn {
1211 - pointer-events: auto;
1212 - display: inline-flex;
1213 - min-height: 36px;
1214 - padding: 7px 16px;
1215 - justify-content: center;
1216 - align-items: center;
1217 - background-color: #ffffff;
1218 - color: #344054;
1219 - border: 1px solid #d0d5dd;
1220 - border-radius: 9px;
1221 - font-size: 16px;
1222 - cursor: pointer;
1223 - text-decoration: none;
1224 - transition: all 0.3s ease 0s;
1225 - box-sizing: border-box;
1226 - }
1227 -
1228 - .generate-button-container .bd-ai-cancel-btn:hover {
1229 - background-color: #f2f4f7;
1230 - color: #101828;
1231 - }
1232 -
1233 - .input-description {
1234 - font-size: 14px;
1235 - color: #667085;
1236 - }
1237 -
1238 - .betterdocs-ai-checkbox-field .form-group {
1239 - display: flex;
1240 - align-items: start;
1241 - /* gap: 200px; */
1242 - justify-content: space-between;
1243 - }
1244 -
1245 - .betterdocs-ai-checkbox-field input {
1246 - width: 20px;
1247 - height: 20px;
1248 - }
1249 -
1250 - /* Add this CSS to your existing stylesheet or create a new one */
1251 -
1252 - .betterdocs-ai-checkbox-field {
1253 - position: relative;
1254 - font-size: 16px;
1255 - /* Adjust font size as needed */
1256 - }
1257 -
1258 - .betterdocs-ai-checkbox-field input[type=checkbox]:checked::before {
1259 - padding: 2px;
1260 - }
1261 -
1262 - .betterdocs-ai-checkbox-field input[type=checkbox] {
1263 - border: 1px solid #00b884;
1264 - }
1265 -
1266 - .betterdocs-ai-checkbox-field input[type=checkbox]:checked::before {
1267 - content: '\2713';
1268 - color: #00b884;
1269 - }
1270 -
1271 - /* Change the default checkbox color */
1272 - .betterdocs-ai-checkbox-field input[type="checkbox"]:hover {
1273 - -webkit-appearance: none;
1274 - /* WebKit/Blink Browsers */
1275 - -moz-appearance: none;
1276 - /* Firefox */
1277 - appearance: none;
1278 - border: 1px solid #00b884;
1279 - /* Set the height of the checkbox */
1280 - /* Optional: Round the corners */
1281 - outline: none;
1282 - /* Remove the default outline */
1283 - }
1284 -
1285 - /* Style the checked state */
1286 - .betterdocs-ai-checkbox-field input[type="checkbox"]:checked {
1287 - /* Set the background color when checked */
1288 - border: 1px solid #00b884;
1289 - /* Set the border color when checked */
1290 - }
1291 -
1292 - .checkbox-field-label {
1293 - width: calc(100% - 150px);
1294 - }
1295 -
1296 - .checkbox-field-label p {
1297 - margin: 0 !important;
1298 - }
1299 -
1300 - .checkbox-input-field {
1301 - width: 300px;
1302 - text-align: right;
1303 - }
1304 -
1305 - .checkbox-input-field {
1306 - position: relative;
1307 - display: inline-block;
1308 - width: 60px;
1309 - height: 34px;
1310 - }
1311 -
1312 - .checkbox-input-field input {
1313 - display: none;
1314 - }
1315 -
1316 - .toggle-label {
1317 - position: absolute;
1318 - cursor: pointer;
1319 - top: 0;
1320 - left: 0;
1321 - right: 0;
1322 - bottom: 0;
1323 - background-color: #ccc;
1324 - border-radius: 34px;
1325 - transition: background-color 0.3s;
1326 - scale: .9;
1327 - width: 52px;
1328 - height: 26px;
1329 - }
1330 -
1331 -
1332 - .checkbox-input-field input:checked+.toggle-label {
1333 - background-color: #00b884;
1334 - }
1335 -
1336 - .toggle-label:after {
1337 - content: "";
1338 - position: absolute;
1339 - height: 22px;
1340 - width: 22px;
1341 - left: 2px;
1342 - bottom: 2px;
1343 - background-color: white;
1344 - border-radius: 50%;
1345 - transition: transform 0.3s;
1346 - }
1347 -
1348 - .checkbox-input-field input:checked+.toggle-label:after {
1349 - transform: translateX(26px);
1350 - }
1351 -
1352 - .generate-btn,
1353 - .prompt-btn,
1354 - .keep-btn {
1355 - display: inline-flex;
1356 - min-height: 0;
1357 - padding: 9px 20px;
1358 - gap: 8px;
1359 - justify-content: center;
1360 - align-items: center;
1361 - background-color: #00b884;
1362 - color: #fff;
1363 - border: 1px solid transparent;
1364 - border-radius: 10px;
1365 - font-size: 13px;
1366 - font-weight: 600;
1367 - cursor: pointer;
1368 - text-decoration: none;
1369 - box-shadow: none;
1370 - transition: background 0.15s, transform 0.05s;
1371 - box-sizing: border-box;
1372 - }
1373 -
1374 - .generate-btn:hover,
1375 - .prompt-btn:hover,
1376 - .keep-btn:hover {
1377 - background-color: #00956b;
1378 - }
1379 -
1380 - .generate-btn[disabled] {
1381 - cursor: unset;
1382 - }
1383 -
1384 - /* Close — 44px gray rounded square INSIDE the modal, top-right of
1385 - the header, aligned with the header icon. */
1386 - .bd-close-button {
1387 - cursor: pointer;
1388 - position: absolute;
1389 - top: 24px;
1390 - right: 24px;
1391 - z-index: 2;
1392 - width: 32px;
1393 - height: 32px;
1394 - display: flex;
1395 - align-items: center;
1396 - justify-content: center;
1397 - border-radius: 9px;
1398 - background: #f2f4f7;
1399 - box-shadow: none;
1400 - color: #667085;
1401 - transition: all 0.2s ease-in-out;
1402 - }
1403 -
1404 - .bd-close-button svg {
1405 - width: 16px;
1406 - height: 16px;
1407 - }
1408 -
1409 - .bd-close-button:hover {
1410 - background: #eaecf0;
1411 - color: #101828;
1412 - }
1413 -
1414 - div#betterdocs-ai-error-message,
1415 - #betterdocs-ai-message {
1416 - font-size: 14px;
1417 - font-family: 'IBM Plex Sans', sans-serif;
1418 - color: #667085;
1419 - margin-bottom: 20px;
1420 - }
1421 -
1422 - div#betterdocs-ai-error-message a,
1423 - #betterdocs-ai-message a {
1424 - text-decoration: none;
1425 - font-weight: 600;
1426 - }
1427 -
1428 - div#betterdocs-ai-error-message a:focus,
1429 - #betterdocs-ai-message a:focus {
1430 - outline: none;
1431 - box-shadow: none;
1432 - color: #2271b1;
1433 - }
1434 -
1435 - #betterdocs-ai-message span {
1436 - color: #d63638;
1437 - }
1438 -
1439 - .warning-message {
1440 - display: flex;
1441 - align-items: center;
1442 - gap: 10px;
1443 - background: #fbebed;
1444 - padding: 12px;
1445 - border-radius: 5px;
1446 - font-size: 14px;
1447 - line-height: 1.4em;
1448 - border-left: 4px solid #d63638;
1449 - }
1450 -
1451 - .warning-message svg {
1452 - width: 20px;
1453 - height: 20px;
1454 - }
1455 -
1456 - .warning-message span {
1457 - color: #d63638;
1458 - }
1459 -
1460 - .warning-message .footer-message {
1461 - width: calc(100% - 20px);
1462 - }
1463 -
1464 - @media only screen and (max-width: 991px) {
1465 - .betterdocs-ai-autowrite-form-container {
1466 - left: 0;
1467 - right: 0;
1468 - transform: translate(0%, -50%);
1469 - }
1470 -
1471 - .betterdocs-ai-autowrite-form-content {
1472 - overflow-x: auto;
1473 - }
1474 - }
1475 -
1476 - @media only screen and (max-width: 740px) {
1477 -
1478 - /* .bd-close-button {
1479 - right: px;
1480 - top: 1px;
1481 - border-radius: 5px;
1482 - width: 12px;
1483 - height: 12px;
1484 - color: #ffffff;
1485 - background: #00b884;
1486 - } */
1487 - .bd-close-button {
1488 - right: 0px;
1489 - top: 0px;
1490 - width: 12px;
1491 - height: 12px;
1492 - font-size: 14px;
1493 - }
1494 -
1495 - }
1496 - </style>
1497 - <?php
1498 - }
1499 - }
775 +}