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 +641 -1287 4.6.0 → 4.9.4 View file →
@@ -12,9 +12,13 @@
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;
20 + use WPDeveloper\BetterDocs\REST\WriteWithAI as RESTWriteWithAI;
17 21
18 22 class WriteWithAI extends Base {
19 23
20 24 public $settings;
@@ -32,11 +36,11 @@
32 36 $post_type = '';
33 37 }
34 38
35 39 if ( ! empty( $this->isEnabledWriteWithAI() ) && 'docs' == $post_type ) {
36 - add_action( 'admin_footer', array( $this, 'ai_autowrite_button' ) );
37 40 add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_ai_edit_assets' ) );
38 41 }
42 + // Legacy AJAX handler kept for back-compat; the redesigned modal uses the REST route.
39 43 add_action( 'wp_ajax_generate_openai_content', array( $this, 'generate_openai_content_callback' ) );
40 44 }
41 45
42 46 public function enqueue_ai_edit_assets( $hook ) {
@@ -48,9 +52,85 @@
48 52 if ( 'docs' !== $post_type ) {
49 53 return;
50 54 }
51 55
52 - if ( empty( $this->get_api_key() ) ) {
56 + $factory = new ProviderFactory( $this->settings );
57 + $api_key = $factory->api_key_for( $factory->active_platform() );
58 + $has_key = ! empty( $api_key );
59 +
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 );
67 +
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;
79 +
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 + ),
128 + )
129 + );
130 +
131 + // AI Edit — only meaningful with a key configured.
132 + if ( ! $has_key ) {
53 133 return;
54 134 }
55 135
56 136 betterdocs()->assets->enqueue( 'betterdocs-ai-edit', 'blocks/ai-edit.js' );
@@ -61,9 +141,11 @@
61 141 'betterdocsAIEdit',
62 142 array(
63 143 'rest_url' => esc_url_raw( rest_url( 'betterdocs/v1/ai-edit' ) ),
64 144 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
65 - 'post_id' => get_the_ID()
145 + 'post_id' => get_the_ID(),
146 + 'instructions' => $this->get_instruction_choices(),
147 + 'actions' => AIEdit::get_localized_actions()
66 148 )
67 149 );
68 150 }
69 151
@@ -73,58 +155,34 @@
73 155 }
74 156
75 157 public function isValidAPIKey( $apiKey ) {
76 158 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;
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 + );
81 163 }
82 164
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;
165 + $factory = new ProviderFactory( $this->settings );
166 + return $factory->validate( $factory->active_platform(), $apiKey );
118 167 }
119 168
120 169 public function get_api_key() {
121 - $api_key = $this->settings->get( 'ai_autowrite_api_key', '' );
122 - return $api_key;
170 + $factory = new ProviderFactory( $this->settings );
171 + return $factory->api_key_for( $factory->active_platform() );
123 172 }
124 173
125 - public function get_system_prompt() {
126 - $prompt = <<<'PROMPT'
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'
127 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.
128 186
129 187 ## Output format
130 188
@@ -146,1354 +204,650 @@
146 204 Do not emit `<style>`, `<script>`, `<iframe>`, `<form>`, or inline `style=""` / `class=""` attributes (other than the `highlight` class above).
147 205
148 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.
149 207 PROMPT;
150 -
151 - return apply_filters( 'betterdocs_write_with_ai_system_prompt', $prompt );
152 208 }
153 209
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' );
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() );
159 221
160 - $api_endpoint = 'https://api.openai.com/v1/chat/completions'; // Update the endpoint based on OpenAI API version
161 -
162 - $request_body = AIHelper::build_openai_payload(
163 - $model,
164 - 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'
177 - );
178 -
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
186 - );
187 -
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.
222 + $instructions = array();
223 + if ( is_array( $stored ) ) {
224 + foreach ( $stored as $item ) {
225 + if ( ! is_array( $item ) || empty( $item['id'] ) ) {
226 + continue;
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 + );
192 233 }
234 + }
193 235
194 - $response = wp_remote_post( $api_endpoint, $request_options );
195 -
196 - if ( is_wp_error( $response ) ) {
197 - return 'Error: ' . $response->get_error_message();
198 - } else {
199 - $body = wp_remote_retrieve_body( $response );
200 -
201 - $data = json_decode( $body, true );
202 -
203 - if ( ! empty( $data[ 'error' ] ) ) {
204 - return $data[ 'error' ][ 'message' ];
205 - }
206 -
207 - 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;
208 242 }
209 - } catch ( Exception $error ) {
210 - return 'Error: ' . $error->getMessage();
211 243 }
212 - }
213 -
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' );
218 -
219 - $api_endpoint = 'https://api.openai.com/v1/chat/completions';
220 -
221 - $payload = AIHelper::build_openai_payload(
222 - $model,
223 - array(
244 + if ( ! $has_default ) {
245 + array_unshift(
246 + $instructions,
224 247 array(
225 - 'role' => 'system',
226 - 'content' => $this->get_system_prompt()
227 - ),
228 - array(
229 - 'role' => 'user',
230 - 'content' => $prompt
248 + 'id' => 'default',
249 + 'title' => __( 'Default/Core', 'betterdocs' ),
250 + 'content' => self::default_instruction_content(),
231 251 )
232 - ),
233 - $max_tokens,
234 - null,
235 - 'write_with_ai'
236 - );
237 -
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 - );
246 -
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.
251 - }
252 -
253 - $response = wp_remote_post( $api_endpoint, $request_options );
254 -
255 - if ( is_wp_error( $response ) ) {
256 - return array(
257 - 'success' => false,
258 - 'error' => $response->get_error_message(),
259 - 'model' => $model
260 252 );
261 253 }
262 254
263 - $body = wp_remote_retrieve_body( $response );
264 - $data = json_decode( $body, true );
255 + return $instructions;
256 + }
265 257
266 - if ( ! empty( $data[ 'error' ] ) ) {
267 - return array(
268 - 'success' => false,
269 - 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
270 - 'model' => $model,
271 - 'raw' => $data
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'],
272 278 );
273 279 }
274 -
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 - return array(
279 - '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
286 - );
280 + return $choices;
287 281 }
288 282
289 283 /**
290 - * Generic chat completion using the Write-with-AI model/token/key settings.
284 + * Resolve selected instruction ids into extra `system` messages.
291 285 *
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()}.
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.
296 289 *
297 - * @param string $user_prompt The user message.
298 - * @param string $system_prompt Optional system message (omitted when empty).
299 - * @return array { success:bool, content?:string, error?:string, model:string, *_tokens?:int }
290 + * @param array $ids
291 + * @return array<int,array{role:string,content:string}>
300 292 */
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' );
293 + public function get_instruction_messages( $ids ) {
294 + if ( empty( $ids ) || ! is_array( $ids ) ) {
295 + return array();
296 + }
305 297
298 + $by_id = array();
299 + foreach ( $this->get_instructions() as $item ) {
300 + $by_id[ $item['id'] ] = $item;
301 + }
302 +
306 303 $messages = array();
307 - if ( $system_prompt !== '' ) {
308 - $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(
309 316 'role' => 'system',
310 - 'content' => $system_prompt
317 + 'content' => $content,
311 318 );
312 319 }
313 - $messages[] = array(
314 - 'role' => 'user',
315 - 'content' => $user_prompt
316 - );
317 320
318 - $api_endpoint = 'https://api.openai.com/v1/chat/completions';
321 + return $messages;
322 + }
319 323
320 - $payload = AIHelper::build_openai_payload( $model, $messages, $max_tokens, null, 'write_with_ai' );
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 + }
321 339
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 - );
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 + }
330 351
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 );
335 - }
352 + if ( '' === $content ) {
353 + continue;
354 + }
336 355
337 - $response = wp_remote_post( $api_endpoint, $request_options );
338 -
339 - if ( is_wp_error( $response ) ) {
340 - return array(
341 - 'success' => false,
342 - 'error' => $response->get_error_message(),
343 - 'model' => $model
356 + $messages[] = array(
357 + 'role' => $role,
358 + 'content' => $content,
344 359 );
345 360 }
346 361
347 - $body = wp_remote_retrieve_body( $response );
348 - $data = json_decode( $body, true );
362 + return $messages;
363 + }
349 364
350 - if ( ! empty( $data[ 'error' ] ) ) {
351 - return array(
352 - 'success' => false,
353 - 'error' => isset( $data[ 'error' ][ 'message' ] ) ? $data[ 'error' ][ 'message' ] : 'OpenAI error',
354 - 'model' => $model,
355 - 'raw' => $data
356 - );
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 + }
357 374 }
358 375
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();
376 + return apply_filters( 'betterdocs_write_with_ai_system_prompt', $prompt );
377 + }
361 378
362 - return array(
363 - '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
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 ) )
370 394 );
395 +
396 + return array( $messages, $this->ai_chat_options( $max_tokens ) );
371 397 }
372 398
373 - public function generate_openai_content_callback() {
374 - // Verify the nonce
375 - $ai_nonce = isset( $_POST['ai_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['ai_nonce'] ) ) : '';
376 - if ( ! wp_verify_nonce( $ai_nonce, 'generate_openai_content_nonce' ) ) {
377 - wp_send_json_error( 'Invalid nonce' );
378 - wp_die();
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 );
379 415 }
380 416
381 - if ( ! current_user_can( 'edit_posts' ) ) {
382 - wp_send_json_error( 'Insufficient permissions' );
383 - wp_die();
384 - }
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 + );
385 422
386 - $prompt = isset( $_POST['prompt'] ) ? sanitize_text_field( wp_unslash( $_POST['prompt'] ) ) : '';
387 - $keywords = isset( $_POST['keywords'] ) ? sanitize_text_field( wp_unslash( $_POST['keywords'] ) ) : '';
388 -
389 - $ai_instance = new WriteWithAI( $this->settings );
390 -
391 - $generated_content = $ai_instance->generate_openai_response( $prompt, $keywords );
392 -
393 - // Count a successful generation (skip obvious upstream errors).
394 - if ( is_string( $generated_content ) && $generated_content !== '' && strpos( $generated_content, 'Error:' ) !== 0 ) {
395 - $post_id = isset( $_POST[ 'post_id' ] ) ? intval( $_POST[ 'post_id' ] ) : 0; //phpcs:ignore
396 - AIUsage::record( 'write_with_ai', $post_id );
423 + if ( null !== $temperature ) {
424 + $options['temperature'] = $temperature;
397 425 }
398 426
399 - // Send the generated content as the AJAX response
400 - wp_send_json_success( $generated_content );
401 - wp_die();
427 + return $options;
402 428 }
403 429
404 - public function ai_autowrite_button() {
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 );
405 433
406 - ?>
407 - <script>
408 - var docsTitle;
434 + $result = ( new ProviderFactory( $this->settings ) )->make()->chat( $messages, $options );
409 435
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 - }
436 + if ( is_wp_error( $result ) ) {
437 + return $result->get_error_message();
438 + }
418 439
419 - function generateArticle(e) {
440 + return $result['content'];
441 + } catch ( \Exception $error ) {
442 + return 'Error: ' . $error->getMessage();
443 + }
444 + }
420 445
446 + /**
447 + * Generate documentation from an uploaded image using a vision-capable model.
448 + *
449 + * Mirrors generate_openai_response() but sends the picture alongside the text
450 + * prompt as an OpenAI-format multimodal user message
451 + * (`content: [ {type:text}, {type:image_url} ]`). The OpenAI-compatible
452 + * provider forwards that message array to the wire verbatim, so no provider
453 + * change is needed. Only OpenAI vision models are wired — Claude and Gemini
454 + * use a different image envelope, so they are refused with a clear error
455 + * instead of being sent a payload they would reject.
456 + *
457 + * @param string $prompt Composed instruction prompt.
458 + * @param array $image { data_uri:string, mime:string }.
459 + * @param int|null $max_tokens Optional token cap.
460 + * @param array $extra_system Extra system messages (instruction sets).
461 + * @return string|\WP_Error Generated content, or WP_Error on guard/failure.
462 + */
463 + public function generate_vision_response( $prompt, $image, $max_tokens = null, $extra_system = array() ) {
464 + if ( empty( $image['data_uri'] ) ) {
465 + return new \WP_Error( 'ai_vision_no_image', __( 'No image data to send to the AI.', 'betterdocs' ) );
466 + }
421 467
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;
468 + $factory = new ProviderFactory( $this->settings );
469 + $platform = $factory->active_platform();
470 + $model = $factory->active_model( $platform );
426 471
427 - // const paragraphOptions = document.getElementById('bd-autowrtite-content-paragraph');
428 - // const numOfParagraphs = paragraphOptions.options[paragraphOptions.selectedIndex].value;
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 + }
429 483
430 - const contentTextArea = document.getElementById('betterdocs-ai-content');
431 - const docGenerateBtnTxt = document.querySelector('.generate-btn span');
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 + );
432 498
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' ); ?>";
499 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( $max_tokens ) );
438 500
501 + if ( is_wp_error( $result ) ) {
502 + return $result;
503 + }
439 504
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.`;
505 + return $result['content'];
506 + } catch ( \Exception $error ) {
507 + return new \WP_Error( 'ai_vision_failed', 'Error: ' . $error->getMessage() );
508 + }
509 + }
441 510
511 + /**
512 + * Transcribe an uploaded recording to plain text.
513 + *
514 + * Deliberately not a doc-generation call: this returns the raw transcript so
515 + * the author can correct it, and generation then runs through the ordinary
516 + * grounded from-source path. That keeps one generation path in the plugin
517 + * instead of a second, media-shaped one.
518 + *
519 + * @since 4.9.4
520 + *
521 + * @param array $file `[ 'path', 'filename', 'mime' ]` — a PHP temp upload.
522 + * @return string|\WP_Error Transcript text.
523 + */
524 + public function transcribe( $file ) {
525 + $factory = new ProviderFactory( $this->settings );
526 + $platform = $factory->active_platform();
442 527
443 - // Check if the title is not empty
444 - if (title.trim() !== '' && keywords.trim() !== '') {
445 - e.preventDefault();
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 + }
446 538
447 - if (!jQuery('#betterdocs-ai-error-message').parent().hasClass('hidden')) {
448 - jQuery('#betterdocs-ai-error-message').parent().addClass('hidden');
449 - }
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 );
450 543
451 - docGenerateBtnTxt.innerHTML = "<?php echo esc_html__( 'Generating...', 'betterdocs' ); ?>";
452 - jQuery('.generate-btn').prop('disabled', true);
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 + }
453 550
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
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 );
468 570
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');
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 + }
482 577
483 - }
484 - },
485 - error: function(jqXHR, textStatus, errorThrown) {
486 - console.error(jqXHR, textStatus, errorThrown);
578 + return false;
579 + }
487 580
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' ); ?>";
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 + }
493 595
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 - });
596 + $model = strtolower( (string) $model );
500 597
501 - }
502 - }
598 + if ( '' === $model || false !== strpos( $model, 'gpt-3.5' ) ) {
599 + return false;
600 + }
503 601
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';
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 + }
510 607
511 - if (language === '') {
512 - language = 'English';
513 - }
608 + return false;
609 + }
514 610
515 - let keywordsText = keywords !== '' ? ` and incorporate the keywords: ${keywords}` : '';
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.
516 614
517 - const contentTextArea = document.getElementById('betterdocs-ai-content');
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" }
518 617
519 - if (!jQuery('#betterdocs-ai-error-message').parent().hasClass('hidden')) {
520 - jQuery('#betterdocs-ai-error-message').parent().addClass('hidden');
521 - }
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;
522 624
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 - }
625 + return apply_filters( 'betterdocs_write_with_ai_outline_system_prompt', $prompt );
626 + }
525 627
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>');
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 );
534 636
535 - return htmlContent;
536 - }
637 + if ( empty( $result['success'] ) ) {
638 + return array(
639 + 'success' => false,
640 + 'error' => isset( $result['error'] ) ? $result['error'] : 'AI error',
641 + );
642 + }
537 643
538 - function replaceHeadingTitle(content, title, overviewTitle) {
539 - let regex = new RegExp(title, 'g');
540 - let updateContent = content.replace(regex, overviewTitle);
541 - return updateContent;
542 - }
644 + $outline = $this->parse_outline( (string) $result['content'] );
543 645
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 - }
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 + }
553 652
653 + return array(
654 + 'success' => true,
655 + 'outline' => $outline,
656 + );
657 + }
554 658
555 - function escapeHtml(str) {
556 - return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
557 - }
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 );
558 670
559 - function escapeRegex(str) {
560 - return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
561 - }
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 + }
562 675
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 - }
676 + $decoded = json_decode( $content, true );
677 + if ( ! is_array( $decoded ) ) {
678 + return array();
679 + }
571 680
572 - function wrapwithHeighlight(inputString, keywords) {
573 - if (!inputString) {
574 - return '';
575 - }
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 + }
576 693
577 - const container = document.createElement('div');
578 - container.innerHTML = inputString;
694 + return $outline;
695 + }
579 696
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 - });
697 + public function generate_openai_response_ai_edit( $prompt, $extra_system = array() ) {
698 + $factory = new ProviderFactory( $this->settings );
699 + $model = $factory->active_model();
591 700
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);
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 + );
597 706
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 - });
707 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
618 708
619 - const textNodes = [];
620 - let current;
621 - while ((current = walker.nextNode())) {
622 - textNodes.push(current);
623 - }
709 + if ( is_wp_error( $result ) ) {
710 + return array(
711 + 'success' => false,
712 + 'error' => $result->get_error_message(),
713 + 'model' => $model
714 + );
715 + }
624 716
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 - }
717 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
640 718
641 - return container.innerHTML;
642 - }
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 + }
643 729
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 - }
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();
655 745
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 );
656 754
755 + $result = $factory->make()->chat( $messages, $this->ai_chat_options() );
657 756
658 - function insertContentToEditor(title, htmlContent, isOverwrite, keywords) {
757 + if ( is_wp_error( $result ) ) {
758 + return array(
759 + 'success' => false,
760 + 'error' => $result->get_error_message(),
761 + 'model' => $model
762 + );
763 + }
659 764
660 - const {
661 - dispatch,
662 - select
663 - } = wp.data;
765 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
664 766
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. ?>`;
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 + }
669 777
670 - const blocks = wp.blocks.rawHandler({
671 - HTML: htmlContent
672 - });
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();
673 794
674 - dispatch('core/editor').editPost({
675 - title: title,
676 - });
795 + $messages = array(
796 + array( 'role' => 'system', 'content' => $system_prompt ),
797 + array( 'role' => 'user', 'content' => $user_prompt )
798 + );
677 799
678 - const selectedBlockClientId = select('core/block-editor').getSelectedBlockClientId();
800 + $result = $factory->make()->chat( $messages, $this->ai_chat_options( null, $temperature ) );
679 801
680 - const allBlocks = select('core/block-editor').getBlocks();
802 + if ( is_wp_error( $result ) ) {
803 + return array(
804 + 'success' => false,
805 + 'error' => $result->get_error_message(),
806 + 'model' => $model
807 + );
808 + }
681 809
682 - if (isOverwrite || isPostContent === '') {
810 + $usage = isset( $result['usage'] ) && is_array( $result['usage'] ) ? $result['usage'] : array();
683 811
684 - allBlocks.forEach((block) => {
685 - dispatch('core/block-editor').removeBlock(block.clientId);
686 - });
687 - dispatch('core/block-editor').insertBlocks(blocks);
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 + }
688 822
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 - }
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 + }
695 830
696 - closeWriteWithAIForm('afterInsertContent');
697 - }
831 + if ( ! current_user_can( 'edit_posts' ) ) {
832 + wp_send_json_error( 'Insufficient permissions' );
833 + wp_die();
834 + }
698 835
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 - }
836 + $prompt = isset( $_POST['prompt'] ) ? sanitize_text_field( wp_unslash( $_POST['prompt'] ) ) : '';
837 + $keywords = isset( $_POST['keywords'] ) ? sanitize_text_field( wp_unslash( $_POST['keywords'] ) ) : '';
704 838
839 + $ai_instance = new WriteWithAI( $this->settings );
705 840
706 - document.addEventListener('DOMContentLoaded', function() {
841 + $generated_content = $ai_instance->generate_openai_response( $prompt, $keywords );
707 842
708 - // Subscribe to changes in the editor state
709 - let previousDocsTitle = '';
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 + }
710 848
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 - }
849 + // Send the generated content as the AJAX response
850 + wp_send_json_success( $generated_content );
851 + wp_die();
852 + }
853 +}