PluginProbe
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor / 2.0.13
Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor v2.0.13
2.0.13 2.0.12 2.0.11 2.0.10 2.0.9 trunk 2.0.2 2.0.3 2.0.4 2.0.5 2.0.6 2.0.7 2.0.8
blockenberg / assets / php / ai-assistant.php

ai-assistant.php in Blockenberg — 600+ Advanced Gutenberg Blocks & AI Agent for WordPress Block Editor 2.0.13, at assets/php/ai-assistant.php

1,728 lines 77.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Blockenberg — AI Agent (OpenRouter)
4 *
5 * Adds a chat panel to the block editor that can build and edit pages with
6 * Blockenberg blocks. The browser never sees the API key: every model call is
7 * proxied through REST routes in this file.
8 *
9 * Routes (namespace blockenberg/v1):
10 * POST /ai/chat — proxy one chat completion (with tool calling) to OpenRouter
11 * GET /ai/models — list tool-capable chat models (cached 1 hour)
12 * GET /ai/image-models — list image-generation models (cached 1 hour)
13 * POST /ai/media — find or generate an image, sideload it into the Media Library
14 *
15 * @package Blockenberg
16 */
17
18 defined( 'ABSPATH' ) || exit;
19
20 define( 'BKBG_AI_OPTION', 'blockenberg_ai_settings' );
21 define( 'BKBG_AI_CSS_META', '_bkbg_ai_custom_css' );
22 define( 'BKBG_AI_MODEL_META', '_bkbg_ai_model' );
23 define( 'BKBG_AI_MODELS_CACHE', 'bkbg_ai_models_v2' );
24 define( 'BKBG_AI_IMAGE_MODELS_CACHE', 'bkbg_ai_image_models_v1' );
25
26 /* ──────────────────────────────────────────────
27 * 1. Settings
28 * ────────────────────────────────────────────── */
29
30 /**
31 * Default settings.
32 *
33 * @return array
34 */
35 function bkbg_ai_default_settings() {
36 return array(
37 'api_key' => '',
38 'model' => 'anthropic/claude-sonnet-4.5',
39 'image_model' => 'google/gemini-2.5-flash-image',
40 'image_source' => 'openverse', // openverse | openrouter
41 'stream' => true,
42 'max_steps' => 0, // Zero disables the step limit.
43 'temperature' => 0.4,
44 'site_context' => '',
45 'open_sidebar' => true,
46 );
47 }
48
49 /**
50 * Get the AI settings merged with defaults.
51 *
52 * @return array
53 */
54 function bkbg_ai_get_settings() {
55 $saved = get_option( BKBG_AI_OPTION, array() );
56 if ( ! is_array( $saved ) ) {
57 $saved = array();
58 }
59 return array_merge( bkbg_ai_default_settings(), $saved );
60 }
61
62 /**
63 * The OpenRouter API key, if configured.
64 *
65 * Also honours the BLOCKENBERG_AI_API_KEY constant (wp-config.php) so the key
66 * can be kept out of the database entirely.
67 *
68 * @return string
69 */
70 function bkbg_ai_get_api_key() {
71 if ( defined( 'BLOCKENBERG_AI_API_KEY' ) && BLOCKENBERG_AI_API_KEY ) {
72 return (string) BLOCKENBERG_AI_API_KEY;
73 }
74 $settings = bkbg_ai_get_settings();
75 return (string) $settings['api_key'];
76 }
77
78 /**
79 * Who may talk to the agent.
80 *
81 * @return bool
82 */
83 function bkbg_ai_user_can_use() {
84 /**
85 * Capability required to use the AI agent. Every request spends credits
86 * on the site's OpenRouter account, so a site can tighten this.
87 *
88 * @param string $capability Capability name.
89 */
90 $capability = apply_filters( 'blockenberg_ai_capability', 'edit_posts' );
91 return current_user_can( $capability );
92 }
93
94 /** Post being edited in the block editor, including when the main query is empty. */
95 function bkbg_ai_editor_post_id() {
96 $post_id = (int) get_the_ID();
97 if ( $post_id ) {
98 return $post_id;
99 }
100 foreach ( array( 'post', 'post_id', 'postId' ) as $key ) {
101 if ( ! isset( $_GET[ $key ] ) ) {
102 continue;
103 }
104 $value = wp_unslash( $_GET[ $key ] );
105 if ( is_numeric( $value ) ) {
106 return absint( $value );
107 }
108 }
109 return 0;
110 }
111
112 /** Model identifiers may contain provider prefixes and routing suffixes. */
113 function bkbg_ai_valid_model_id( $model ) {
114 return is_string( $model ) && 1 === preg_match( '#^[a-z0-9][a-z0-9._:/+@-]{0,199}$#i', $model );
115 }
116
117 /** Read the current user's last selection without exposing account settings. */
118 function bkbg_ai_get_preferred_model() {
119 $model = get_user_meta( get_current_user_id(), BKBG_AI_MODEL_META, true );
120 return bkbg_ai_valid_model_id( $model ) ? $model : '';
121 }
122
123 /** Validate form settings while keeping saved secrets and disabled fields. */
124 function bkbg_ai_sanitize_settings( $posted, $current ) {
125 $settings = $current;
126 $key = isset( $posted['api_key'] ) ? trim( sanitize_text_field( $posted['api_key'] ) ) : '';
127 if ( ! defined( 'BLOCKENBERG_AI_API_KEY' ) || ! BLOCKENBERG_AI_API_KEY ) {
128 if ( ! empty( $posted['remove_api_key'] ) ) {
129 $settings['api_key'] = '';
130 } elseif ( '' !== $key && ! preg_match( '/^[\x{2022}\s]+$/u', $key ) ) {
131 $settings['api_key'] = $key;
132 }
133 }
134 foreach ( array( 'model', 'image_model' ) as $field ) {
135 $model = isset( $posted[ $field ] ) ? sanitize_text_field( $posted[ $field ] ) : '';
136 if ( bkbg_ai_valid_model_id( $model ) ) {
137 $settings[ $field ] = $model;
138 }
139 }
140 if ( isset( $posted['image_source'] ) && in_array( $posted['image_source'], array( 'openverse', 'openrouter' ), true ) ) {
141 $settings['image_source'] = $posted['image_source'];
142 }
143 $settings['stream'] = ! empty( $posted['stream'] );
144 if ( isset( $posted['max_steps'] ) && is_numeric( $posted['max_steps'] ) ) {
145 $settings['max_steps'] = max( 0, min( 40, (int) $posted['max_steps'] ) );
146 }
147 if ( isset( $posted['temperature'] ) && is_numeric( $posted['temperature'] ) ) {
148 $settings['temperature'] = max( 0, min( 2, (float) $posted['temperature'] ) );
149 }
150 if ( isset( $posted['site_context'] ) ) {
151 $settings['site_context'] = sanitize_textarea_field( $posted['site_context'] );
152 }
153 $settings['open_sidebar'] = ! empty( $posted['open_sidebar'] );
154 return $settings;
155 }
156
157 /* ──────────────────────────────────────────────
158 * 2. Settings screen (Blockenberg → AI Agent)
159 * ────────────────────────────────────────────── */
160
161 add_action( 'admin_menu', function () {
162 add_submenu_page(
163 'blockenberg',
164 __( 'AI Agent', 'blockenberg' ),
165 __( 'AI Agent', 'blockenberg' ),
166 'manage_options',
167 'blockenberg-ai',
168 'bkbg_ai_render_settings_page'
169 );
170 }, 20 );
171
172 /**
173 * Save handler for the settings form.
174 */
175 add_action( 'admin_post_bkbg_ai_save_settings', function () {
176 if ( ! current_user_can( 'manage_options' ) ) {
177 wp_die( esc_html__( 'You are not allowed to do this.', 'blockenberg' ), '', array( 'response' => 403 ) );
178 }
179 check_admin_referer( 'bkbg_ai_settings' );
180
181 $settings = bkbg_ai_sanitize_settings( wp_unslash( $_POST ), bkbg_ai_get_settings() );
182
183 update_option( BKBG_AI_OPTION, $settings, false );
184
185 wp_safe_redirect( add_query_arg( 'bkbg-updated', '1', admin_url( 'admin.php?page=blockenberg-ai' ) ) );
186 exit;
187 } );
188
189 /**
190 * AJAX — verify the key by asking OpenRouter who we are.
191 */
192 add_action( 'wp_ajax_bkbg_ai_test_key', function () {
193 check_ajax_referer( 'bkbg_ai_test', 'nonce' );
194 if ( ! current_user_can( 'manage_options' ) ) {
195 wp_send_json_error( array( 'message' => __( 'Unauthorized', 'blockenberg' ) ), 403 );
196 }
197
198 // Test what is in the field right now, so a key can be verified before it is
199 // saved. An untouched masked field falls back to the stored key.
200 $posted = isset( $_POST['api_key'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) ) : '';
201 $stored = bkbg_ai_get_api_key();
202 $is_mask = (bool) preg_match( '/^[\x{2022}\s]+$/u', $posted );
203 $unsaved = ( '' !== $posted && ! $is_mask && $posted !== $stored );
204 $key = $unsaved ? $posted : $stored;
205
206 if ( '' === $key ) {
207 wp_send_json_error( array( 'message' => __( 'Enter an API key first.', 'blockenberg' ) ) );
208 }
209
210 $response = wp_remote_get( 'https://openrouter.ai/api/v1/key', array(
211 'timeout' => 20,
212 'headers' => array( 'Authorization' => 'Bearer ' . $key ),
213 ) );
214
215 if ( is_wp_error( $response ) ) {
216 wp_send_json_error( array( 'message' => $response->get_error_message() ) );
217 }
218
219 $code = wp_remote_retrieve_response_code( $response );
220 $body = json_decode( wp_remote_retrieve_body( $response ), true );
221
222 if ( 200 !== (int) $code ) {
223 $msg = isset( $body['error']['message'] ) ? $body['error']['message'] : sprintf( 'HTTP %d', $code );
224 wp_send_json_error( array( 'message' => $msg ) );
225 }
226
227 $label = __( 'Key is valid.', 'blockenberg' );
228 if ( isset( $body['data']['limit_remaining'] ) && null !== $body['data']['limit_remaining'] ) {
229 $label .= ' ' . sprintf(
230 /* translators: %s: remaining credit */
231 __( 'Remaining credit: %s', 'blockenberg' ),
232 '$' . number_format_i18n( (float) $body['data']['limit_remaining'], 2 )
233 );
234 }
235 if ( $unsaved ) {
236 $label .= ' ' . __( 'Save the settings to start using it.', 'blockenberg' );
237 }
238
239 wp_send_json_success( array( 'message' => $label, 'unsaved' => $unsaved ) );
240 } );
241
242 /**
243 * Render the settings screen.
244 */
245 function bkbg_ai_render_settings_page() {
246 $s = bkbg_ai_get_settings();
247 $const = defined( 'BLOCKENBERG_AI_API_KEY' ) && BLOCKENBERG_AI_API_KEY;
248 $has_key = '' !== bkbg_ai_get_api_key();
249 ?>
250 <div class="wrap bkbg-ai-settings">
251 <header class="bkbg-ai-settings-header">
252 <div>
253 <p class="bkbg-ai-settings-eyebrow">Blockenberg</p>
254 <h1><?php esc_html_e( 'AI Agent', 'blockenberg' ); ?></h1>
255 <p><?php esc_html_e( 'Build and edit pages by chatting in the WordPress editor.', 'blockenberg' ); ?></p>
256 </div>
257 <span id="bkbg-ai-connection-status" class="bkbg-ai-status<?php echo $has_key ? ' is-configured' : ''; ?>">
258 <span class="bkbg-ai-status-dot" aria-hidden="true"></span>
259 <span><?php echo $has_key ? esc_html__( 'Key configured', 'blockenberg' ) : esc_html__( 'Not configured', 'blockenberg' ); ?></span>
260 </span>
261 </header>
262
263 <?php if ( isset( $_GET['bkbg-updated'] ) ) : ?>
264 <div class="notice notice-success is-dismissible"><p><?php esc_html_e( 'Settings saved.', 'blockenberg' ); ?></p></div>
265 <?php endif; ?>
266
267 <form id="bkbg-ai-settings-form" method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>">
268 <input type="hidden" name="action" value="bkbg_ai_save_settings" />
269 <?php wp_nonce_field( 'bkbg_ai_settings' ); ?>
270
271 <div class="bkbg-ai-settings-grid">
272 <section class="bkbg-ai-settings-card" aria-labelledby="bkbg-ai-connection-heading">
273 <div class="bkbg-ai-card-heading">
274 <span class="dashicons dashicons-admin-links" aria-hidden="true"></span>
275 <div><h2 id="bkbg-ai-connection-heading"><?php esc_html_e( 'Connection', 'blockenberg' ); ?></h2>
276 <p><?php esc_html_e( 'OpenRouter powers the built-in chat. It is optional.', 'blockenberg' ); ?></p></div>
277 </div>
278 <div class="bkbg-ai-field-note"><?php esc_html_e( 'You can also connect Codex, Claude, or Cursor from the editor chat. That path does not need an OpenRouter API key.', 'blockenberg' ); ?></div>
279 <?php if ( $const ) : ?>
280 <div class="bkbg-ai-field-note"><?php esc_html_e( 'The API key is managed in wp-config.php.', 'blockenberg' ); ?></div>
281 <?php else : ?>
282 <label class="bkbg-ai-field-label" for="bkbg-ai-key"><?php esc_html_e( 'OpenRouter API key', 'blockenberg' ); ?></label>
283 <input type="password" id="bkbg-ai-key" name="api_key" autocomplete="new-password" value=""
284 placeholder="<?php echo $has_key ? esc_attr__( 'Saved key — leave blank to keep it', 'blockenberg' ) : 'sk-or-v1-…'; ?>"
285 aria-describedby="bkbg-ai-key-help" />
286 <p id="bkbg-ai-key-help" class="bkbg-ai-help"><?php esc_html_e( 'Your saved key stays on the server. Enter a new key to replace it.', 'blockenberg' ); ?></p>
287 <?php if ( ! empty( $s['api_key'] ) ) : ?>
288 <label class="bkbg-ai-check bkbg-ai-remove-key"><input type="checkbox" id="bkbg-ai-remove-key" name="remove_api_key" value="1" /><?php esc_html_e( 'Remove the saved key when I save', 'blockenberg' ); ?></label>
289 <?php endif; ?>
290 <?php endif; ?>
291 <div class="bkbg-ai-field-actions">
292 <button type="button" class="button" id="bkbg-ai-test"><span class="dashicons dashicons-yes-alt" aria-hidden="true"></span><?php esc_html_e( 'Test connection', 'blockenberg' ); ?></button>
293 <a href="https://openrouter.ai/keys" target="_blank" rel="noopener noreferrer"><?php esc_html_e( 'Get an API key', 'blockenberg' ); ?><span class="dashicons dashicons-external" aria-hidden="true"></span></a>
294 </div>
295 <p id="bkbg-ai-test-result" class="bkbg-ai-inline-status" role="status" aria-live="polite"></p>
296 </section>
297
298 <section class="bkbg-ai-settings-card" aria-labelledby="bkbg-ai-model-heading">
299 <div class="bkbg-ai-card-heading">
300 <span class="dashicons dashicons-format-chat" aria-hidden="true"></span>
301 <div><h2 id="bkbg-ai-model-heading"><?php esc_html_e( 'Default model', 'blockenberg' ); ?></h2>
302 <p><?php esc_html_e( 'The starting model for people using the agent.', 'blockenberg' ); ?></p></div>
303 </div>
304 <div id="bkbg-ai-model-picker-wrap" hidden>
305 <label class="bkbg-ai-field-label" id="bkbg-ai-model-label" for="bkbg-ai-model-trigger"><?php esc_html_e( 'Chat model', 'blockenberg' ); ?></label>
306 <div class="bkbg-ai-model-combobox" id="bkbg-ai-model-combobox">
307 <button type="button" id="bkbg-ai-model-trigger" class="bkbg-ai-model-combobox__trigger" aria-labelledby="bkbg-ai-model-label" aria-describedby="bkbg-ai-model-help bkbg-ai-model-price" aria-haspopup="listbox" aria-expanded="false" aria-controls="bkbg-ai-model-results">
308 <span id="bkbg-ai-model-trigger-label" class="bkbg-ai-model-combobox__value"></span>
309 <span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
310 </button>
311 <div class="bkbg-ai-model-combobox__panel" id="bkbg-ai-model-panel" hidden>
312 <input type="search" id="bkbg-ai-model-search" placeholder="<?php esc_attr_e( 'Search models…', 'blockenberg' ); ?>" aria-label="<?php esc_attr_e( 'Search models', 'blockenberg' ); ?>" aria-controls="bkbg-ai-model-results" aria-autocomplete="list" autocomplete="off" />
313 <div class="bkbg-ai-model-combobox__results" id="bkbg-ai-model-results" role="listbox" aria-labelledby="bkbg-ai-model-label"></div>
314 </div>
315 </div>
316 </div>
317 <div id="bkbg-ai-custom-model-wrap">
318 <label class="bkbg-ai-field-label" for="bkbg-ai-model"><?php esc_html_e( 'Custom model ID', 'blockenberg' ); ?></label>
319 <input type="text" id="bkbg-ai-model" name="model" value="<?php echo esc_attr( $s['model'] ); ?>"
320 spellcheck="false" autocomplete="off" placeholder="minimax/minimax-m3:free" />
321 </div>
322 <p id="bkbg-ai-model-loading" class="bkbg-ai-help" role="status"></p>
323 <p id="bkbg-ai-model-price" class="bkbg-ai-model-price" aria-live="polite"></p>
324 <p id="bkbg-ai-model-help" class="bkbg-ai-help"><?php esc_html_e( 'Type to search by name or model ID. Free models appear first (not guaranteed — they often error, but are fine for small fixes), then paid models, each in alphabetical order. Only models that support tools are listed.', 'blockenberg' ); ?></p>
325 <div class="bkbg-ai-field-note"><?php esc_html_e( 'The editor remembers each person’s last selected model. Changing this default keeps their choice.', 'blockenberg' ); ?></div>
326 </section>
327
328 <section class="bkbg-ai-settings-card" aria-labelledby="bkbg-ai-images-heading">
329 <div class="bkbg-ai-card-heading">
330 <span class="dashicons dashicons-format-image" aria-hidden="true"></span>
331 <div><h2 id="bkbg-ai-images-heading"><?php esc_html_e( 'Images', 'blockenberg' ); ?></h2>
332 <p><?php esc_html_e( 'Choose how the agent illustrates your pages.', 'blockenberg' ); ?></p></div>
333 </div>
334 <fieldset class="bkbg-ai-image-choices">
335 <legend class="screen-reader-text"><?php esc_html_e( 'Default image source', 'blockenberg' ); ?></legend>
336 <label class="bkbg-ai-radio-card"><input type="radio" name="image_source" value="openverse" <?php checked( 'openverse', $s['image_source'] ); ?> />
337 <span><strong><?php esc_html_e( 'Search free photos', 'blockenberg' ); ?></strong><small><?php esc_html_e( 'Find openly licensed images with Openverse and Wikimedia Commons.', 'blockenberg' ); ?></small></span>
338 </label>
339 <label class="bkbg-ai-radio-card"><input type="radio" name="image_source" value="openrouter" <?php checked( 'openrouter', $s['image_source'] ); ?> />
340 <span><strong><?php esc_html_e( 'Generate with AI', 'blockenberg' ); ?></strong><small><?php esc_html_e( 'Create images with OpenRouter. Image generation has its own model pricing, even when chat is free.', 'blockenberg' ); ?></small></span>
341 </label>
342 </fieldset>
343 <div id="bkbg-ai-image-model-wrap" class="bkbg-ai-image-model-field">
344 <div id="bkbg-ai-image-model-picker-wrap" hidden>
345 <label class="bkbg-ai-field-label" id="bkbg-ai-image-model-label" for="bkbg-ai-image-model-trigger"><?php esc_html_e( 'Image model', 'blockenberg' ); ?></label>
346 <div class="bkbg-ai-model-combobox" id="bkbg-ai-image-model-combobox">
347 <button type="button" id="bkbg-ai-image-model-trigger" class="bkbg-ai-model-combobox__trigger" aria-labelledby="bkbg-ai-image-model-label" aria-describedby="bkbg-ai-image-help bkbg-ai-image-model-price" aria-haspopup="listbox" aria-expanded="false" aria-controls="bkbg-ai-image-model-results">
348 <span id="bkbg-ai-image-model-trigger-label" class="bkbg-ai-model-combobox__value"></span>
349 <span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
350 </button>
351 <div class="bkbg-ai-model-combobox__panel" id="bkbg-ai-image-model-panel" hidden>
352 <input type="search" id="bkbg-ai-image-model-search" placeholder="<?php esc_attr_e( 'Search image models…', 'blockenberg' ); ?>" aria-label="<?php esc_attr_e( 'Search image models', 'blockenberg' ); ?>" aria-controls="bkbg-ai-image-model-results" aria-autocomplete="list" autocomplete="off" />
353 <div class="bkbg-ai-model-combobox__results" id="bkbg-ai-image-model-results" role="listbox" aria-labelledby="bkbg-ai-image-model-label"></div>
354 </div>
355 </div>
356 </div>
357 <div id="bkbg-ai-image-custom-model-wrap">
358 <label class="bkbg-ai-field-label" for="bkbg-ai-image-model"><?php esc_html_e( 'Custom model ID', 'blockenberg' ); ?></label>
359 <input type="text" id="bkbg-ai-image-model" name="image_model" value="<?php echo esc_attr( $s['image_model'] ); ?>" spellcheck="false" autocomplete="off" placeholder="google/gemini-2.5-flash-image" />
360 </div>
361 <p id="bkbg-ai-image-model-loading" class="bkbg-ai-help" role="status"></p>
362 <p id="bkbg-ai-image-model-price" class="bkbg-ai-model-price" aria-live="polite"></p>
363 <p id="bkbg-ai-image-help" class="bkbg-ai-help"><?php esc_html_e( 'Type to search image models from OpenRouter. Used only when Generate with AI is selected. Every image is saved to the Media Library.', 'blockenberg' ); ?></p>
364 </div>
365 </section>
366
367 <section class="bkbg-ai-settings-card" aria-labelledby="bkbg-ai-context-heading">
368 <div class="bkbg-ai-card-heading">
369 <span class="dashicons dashicons-admin-site-alt3" aria-hidden="true"></span>
370 <div><h2 id="bkbg-ai-context-heading"><?php esc_html_e( 'Site & brand context', 'blockenberg' ); ?></h2>
371 <p><?php esc_html_e( 'Give every conversation a useful starting point.', 'blockenberg' ); ?></p></div>
372 </div>
373 <label class="bkbg-ai-field-label" for="bkbg-ai-context"><?php esc_html_e( 'What should the agent know?', 'blockenberg' ); ?></label>
374 <textarea id="bkbg-ai-context" name="site_context" rows="8" aria-describedby="bkbg-ai-context-help" placeholder="<?php esc_attr_e( 'Who the site is for, your tone of voice, brand colors, fonts, and details to include in the content.', 'blockenberg' ); ?>"><?php echo esc_textarea( $s['site_context'] ); ?></textarea>
375 <p id="bkbg-ai-context-help" class="bkbg-ai-help"><?php esc_html_e( 'Included in every model request. Keep it relevant and avoid passwords or private credentials.', 'blockenberg' ); ?></p>
376 </section>
377
378 <section class="bkbg-ai-settings-card" aria-labelledby="bkbg-ai-editor-heading">
379 <div class="bkbg-ai-card-heading">
380 <span class="dashicons dashicons-align-pull-right" aria-hidden="true"></span>
381 <div><h2 id="bkbg-ai-editor-heading"><?php esc_html_e( 'Editor', 'blockenberg' ); ?></h2>
382 <p><?php esc_html_e( 'How the agent appears in the block editor.', 'blockenberg' ); ?></p></div>
383 </div>
384 <label class="bkbg-ai-check"><input type="checkbox" name="open_sidebar" value="1" <?php checked( ! empty( $s['open_sidebar'] ) ); ?> /><?php esc_html_e( 'Open the AI Agent when the editor loads', 'blockenberg' ); ?></label>
385 <p class="bkbg-ai-help"><?php esc_html_e( 'The chat opens instead of page settings. You can still close it; this applies the next time you edit a page.', 'blockenberg' ); ?></p>
386 </section>
387 </div>
388
389 <details class="bkbg-ai-settings-advanced">
390 <summary><span class="dashicons dashicons-admin-generic" aria-hidden="true"></span><span><?php esc_html_e( 'Advanced settings', 'blockenberg' ); ?></span><small><?php esc_html_e( 'Streaming, step limit, and creativity', 'blockenberg' ); ?></small></summary>
391 <div class="bkbg-ai-advanced-fields">
392 <div><label class="bkbg-ai-check"><input type="checkbox" name="stream" value="1" <?php checked( ! empty( $s['stream'] ) ); ?> /><?php esc_html_e( 'Stream responses', 'blockenberg' ); ?></label>
393 <p class="bkbg-ai-help"><?php esc_html_e( 'Show replies as they are written. Falls back to a complete reply when streaming is unavailable.', 'blockenberg' ); ?></p></div>
394 <div><label class="bkbg-ai-field-label" for="bkbg-ai-steps"><?php esc_html_e( 'Step limit per message', 'blockenberg' ); ?></label>
395 <input type="number" id="bkbg-ai-steps" name="max_steps" min="0" max="40" step="1" aria-describedby="bkbg-ai-steps-help" value="<?php echo esc_attr( $s['max_steps'] ); ?>" />
396 <p id="bkbg-ai-steps-help" class="bkbg-ai-help"><?php esc_html_e( '0 = no limit (default). The agent works until it finishes or you press Stop. Set 1–40 to pause after a fixed number of steps.', 'blockenberg' ); ?></p></div>
397 <div><label class="bkbg-ai-field-label" for="bkbg-ai-temp"><?php esc_html_e( 'Creativity', 'blockenberg' ); ?></label>
398 <input type="number" id="bkbg-ai-temp" name="temperature" min="0" max="2" step="0.1" value="<?php echo esc_attr( $s['temperature'] ); ?>" />
399 <p class="bkbg-ai-help"><?php esc_html_e( 'Lower values favor consistency; higher values add variety. The default is 0.4.', 'blockenberg' ); ?></p></div>
400 </div>
401 </details>
402
403 <footer class="bkbg-ai-settings-footer">
404 <button type="submit" class="button button-primary"><?php esc_html_e( 'Save settings', 'blockenberg' ); ?></button>
405 <span id="bkbg-ai-save-status" role="status" aria-live="polite"><?php esc_html_e( 'Changes apply after saving.', 'blockenberg' ); ?></span>
406 </footer>
407 </form>
408 </div>
409 <?php
410 }
411
412 /** Load settings assets only on the agent settings screen. */
413 add_action( 'admin_enqueue_scripts', function ( $hook ) {
414 if ( 'blockenberg_page_blockenberg-ai' !== $hook ) {
415 return;
416 }
417 $plugin_dir = dirname( __DIR__, 2 );
418 $plugin_url = plugins_url( '', $plugin_dir . '/blockenberg.php' );
419 $css = $plugin_dir . '/assets/css/ai-settings.css';
420 $js = $plugin_dir . '/assets/js/ai-settings.js';
421 wp_enqueue_style( 'bkbg-ai-settings', $plugin_url . '/assets/css/ai-settings.css', array(), filemtime( $css ) );
422 wp_enqueue_script( 'bkbg-ai-settings', $plugin_url . '/assets/js/ai-settings.js', array( 'wp-i18n' ), filemtime( $js ), true );
423 wp_add_inline_script( 'bkbg-ai-settings', 'window.bkbgAISettings = ' . wp_json_encode( array(
424 'restBase' => esc_url_raw( rest_url( 'blockenberg/v1' ) ),
425 'nonce' => wp_create_nonce( 'wp_rest' ),
426 'testNonce' => wp_create_nonce( 'bkbg_ai_test' ),
427 'configured' => '' !== bkbg_ai_get_api_key(),
428 ) ) . ';', 'before' );
429 wp_set_script_translations( 'bkbg-ai-settings', 'blockenberg' );
430 } );
431
432 /* ──────────────────────────────────────────────
433 * 3. Custom CSS written by the agent
434 * ────────────────────────────────────────────── */
435
436 add_action( 'init', function () {
437 register_post_meta( '', BKBG_AI_CSS_META, array(
438 'type' => 'string',
439 'single' => true,
440 'default' => '',
441 'show_in_rest' => true,
442 'sanitize_callback' => 'bkbg_ai_sanitize_css',
443 'auth_callback' => function ( $allowed, $meta_key, $post_id ) {
444 return current_user_can( 'edit_post', $post_id );
445 },
446 ) );
447 } );
448
449 /**
450 * Keep CSS as CSS: no tags, no closing style element.
451 *
452 * @param string $css Raw CSS.
453 * @return string
454 */
455 function bkbg_ai_sanitize_css( $css ) {
456 $css = (string) $css;
457 $css = preg_replace( '#</?\s*(style|script)[^>]*>#i', '', $css );
458 // A literal '<' cannot occur in an HTML raw-text style element safely.
459 // Keep '>', however: it is the CSS child combinator and is also valid in
460 // media queries. Removing it silently changes the rules the user sees.
461 $css = str_replace( '<', '', $css );
462 return trim( wp_check_invalid_utf8( $css ) );
463 }
464
465 /**
466 * Print per-post custom CSS on the front end.
467 */
468 add_action( 'wp_head', function () {
469 if ( ! is_singular() ) {
470 return;
471 }
472 $css = get_post_meta( get_queried_object_id(), BKBG_AI_CSS_META, true );
473 if ( ! $css ) {
474 return;
475 }
476 echo "\n<style id=\"bkbg-ai-custom-css\">\n" . bkbg_ai_sanitize_css( $css ) . "\n</style>\n";
477 }, 99 );
478
479 /* ──────────────────────────────────────────────
480 * 4. REST API
481 * ────────────────────────────────────────────── */
482
483 add_action( 'rest_api_init', function () {
484 $permission = function () {
485 return bkbg_ai_user_can_use()
486 ? true
487 : new WP_Error( 'bkbg_ai_forbidden', __( 'You cannot use the AI agent.', 'blockenberg' ), array( 'status' => 403 ) );
488 };
489
490 register_rest_route( 'blockenberg/v1', '/ai/chat', array(
491 'methods' => 'POST',
492 'permission_callback' => $permission,
493 'callback' => 'bkbg_ai_rest_chat',
494 ) );
495
496 register_rest_route( 'blockenberg/v1', '/ai/models', array(
497 'methods' => 'GET',
498 'permission_callback' => $permission,
499 'callback' => 'bkbg_ai_rest_models',
500 ) );
501
502 register_rest_route( 'blockenberg/v1', '/ai/image-models', array(
503 'methods' => 'GET',
504 'permission_callback' => $permission,
505 'callback' => 'bkbg_ai_rest_image_models',
506 ) );
507
508 register_rest_route( 'blockenberg/v1', '/ai/preferences', array(
509 array(
510 'methods' => 'GET',
511 'permission_callback' => $permission,
512 'callback' => function () {
513 return rest_ensure_response( array( 'model' => bkbg_ai_get_preferred_model() ) );
514 },
515 ),
516 array(
517 'methods' => 'POST',
518 'permission_callback' => $permission,
519 'callback' => 'bkbg_ai_rest_save_preferences',
520 ),
521 ) );
522
523 register_rest_route( 'blockenberg/v1', '/ai/media', array(
524 'methods' => 'POST',
525 'permission_callback' => function () {
526 return bkbg_ai_user_can_use() && current_user_can( 'upload_files' )
527 ? true
528 : new WP_Error( 'bkbg_ai_forbidden', __( 'You cannot create images with the AI agent.', 'blockenberg' ), array( 'status' => 403 ) );
529 },
530 'callback' => 'bkbg_ai_rest_media',
531 ) );
532 } );
533
534 /** Save only this user's model choice; this endpoint cannot alter site settings. */
535 function bkbg_ai_rest_save_preferences( WP_REST_Request $request ) {
536 $params = $request->get_json_params();
537 if ( ! is_array( $params ) || ! isset( $params['model'] ) ||
538 ( '' !== $params['model'] && ! bkbg_ai_valid_model_id( $params['model'] ) ) ) {
539 return new WP_Error( 'bkbg_ai_bad_request', __( 'Choose a valid model.', 'blockenberg' ), array( 'status' => 400 ) );
540 }
541 $model = $params['model'];
542 $user_id = get_current_user_id();
543 if ( '' === $model ) {
544 delete_user_meta( $user_id, BKBG_AI_MODEL_META );
545 } else {
546 update_user_meta( $user_id, BKBG_AI_MODEL_META, $model );
547 }
548 if ( bkbg_ai_get_preferred_model() !== $model ) {
549 return new WP_Error( 'bkbg_ai_preference_save', __( 'The model preference could not be saved. Please try again.', 'blockenberg' ), array( 'status' => 500 ) );
550 }
551 return rest_ensure_response( array( 'model' => $model ) );
552 }
553
554 /**
555 * Proxy a single chat completion to OpenRouter.
556 *
557 * @param WP_REST_Request $request Request.
558 * @return WP_REST_Response|WP_Error
559 */
560 function bkbg_ai_rest_chat( WP_REST_Request $request ) {
561 $key = bkbg_ai_get_api_key();
562 if ( '' === $key ) {
563 return new WP_Error( 'bkbg_ai_no_key', __( 'No OpenRouter API key is configured. Add one under Blockenberg → AI Agent.', 'blockenberg' ), array( 'status' => 400 ) );
564 }
565
566 $settings = bkbg_ai_get_settings();
567 $params = $request->get_json_params();
568 if ( ! is_array( $params ) ) {
569 $params = array();
570 }
571
572 $messages = isset( $params['messages'] ) ? $params['messages'] : null;
573 if ( ! is_array( $messages ) || empty( $messages ) ) {
574 return new WP_Error( 'bkbg_ai_bad_request', __( 'No messages supplied.', 'blockenberg' ), array( 'status' => 400 ) );
575 }
576
577 // Conversations grow with every tool result; keep a sane ceiling.
578 if ( strlen( (string) wp_json_encode( $messages ) ) > 2000000 ) {
579 return new WP_Error(
580 'bkbg_ai_too_large',
581 __( 'This conversation has grown too large. Clear the chat and start a fresh one.', 'blockenberg' ),
582 array( 'status' => 413 )
583 );
584 }
585
586 $payload = array(
587 'model' => isset( $params['model'] ) && $params['model'] ? sanitize_text_field( $params['model'] ) : $settings['model'],
588 'messages' => $messages,
589 );
590
591 if ( isset( $params['tools'] ) && is_array( $params['tools'] ) ) {
592 $payload['tools'] = $params['tools'];
593 $payload['tool_choice'] = isset( $params['tool_choice'] ) ? $params['tool_choice'] : 'auto';
594 }
595
596 $payload['temperature'] = isset( $params['temperature'] )
597 ? max( 0, min( 2, (float) $params['temperature'] ) )
598 : (float) $settings['temperature'];
599
600 if ( isset( $params['max_tokens'] ) ) {
601 $payload['max_tokens'] = max( 256, min( 32000, (int) $params['max_tokens'] ) );
602 }
603
604 // Ask OpenRouter to report what the call cost.
605 if ( ! empty( $params['usage']['include'] ) ) {
606 $payload['usage'] = array( 'include' => true );
607 }
608
609 if ( ! empty( $params['stream'] ) && ! empty( $settings['stream'] ) && function_exists( 'curl_init' ) ) {
610 bkbg_ai_stream_completion( $payload ); // Writes SSE and exits.
611 }
612
613 $response = bkbg_ai_openrouter_post( 'chat/completions', $payload, 180 );
614 if ( is_wp_error( $response ) ) {
615 return $response;
616 }
617
618 return rest_ensure_response( $response );
619 }
620
621 /**
622 * Pipe a streaming completion straight through to the browser as SSE.
623 *
624 * Never returns: the response is written by hand so chunks reach the panel as
625 * the model writes them.
626 *
627 * @param array $payload Chat completion payload.
628 * @return void
629 */
630 function bkbg_ai_stream_completion( $payload ) {
631 $payload['stream'] = true;
632
633 // Turn off everything that would hold the bytes back.
634 @ini_set( 'zlib.output_compression', '0' );
635 @ini_set( 'implicit_flush', '1' );
636 @ini_set( 'max_execution_time', '300' );
637 while ( ob_get_level() > 0 ) {
638 if ( ! @ob_end_flush() ) {
639 break; // Some hosting providers install non-removable buffers.
640 }
641 }
642 @ob_implicit_flush( true );
643 // Let the cURL callback close the upstream connection when the user stops
644 // the reply, including on hosts configured to ignore aborted requests.
645 ignore_user_abort( true );
646
647 nocache_headers();
648 header( 'Content-Type: text/event-stream; charset=utf-8' );
649 header( 'Cache-Control: no-cache, no-store, must-revalidate' );
650 header( 'X-Accel-Buffering: no' ); // nginx
651 header( 'Connection: keep-alive' );
652
653 $status = 0;
654 $is_sse = false;
655 $stream_tail = '';
656 $saw_done = false;
657 $error_body = '';
658
659 $handle = curl_init( 'https://openrouter.ai/api/v1/chat/completions' );
660 curl_setopt_array( $handle, array(
661 CURLOPT_POST => true,
662 CURLOPT_POSTFIELDS => wp_json_encode( $payload ),
663 CURLOPT_RETURNTRANSFER => false,
664 CURLOPT_CONNECTTIMEOUT => 20,
665 CURLOPT_TIMEOUT => 300,
666 CURLOPT_CAINFO => ABSPATH . WPINC . '/certificates/ca-bundle.crt',
667 CURLOPT_HTTPHEADER => array(
668 'Authorization: Bearer ' . bkbg_ai_get_api_key(),
669 'Content-Type: application/json',
670 'Accept: text/event-stream',
671 'HTTP-Referer: ' . home_url( '/' ),
672 'X-Title: Blockenberg AI Agent',
673 ),
674 CURLOPT_HEADERFUNCTION => function ( $handle, $header ) use ( &$status, &$is_sse ) {
675 if ( preg_match( '#^HTTP/\S+\s+(\d{3})#', $header, $match ) ) {
676 $status = (int) $match[1];
677 $is_sse = false;
678 } elseif ( 0 === stripos( $header, 'Content-Type:' ) ) {
679 $is_sse = false !== stripos( $header, 'text/event-stream' );
680 }
681 return strlen( $header );
682 },
683 CURLOPT_WRITEFUNCTION => function ( $handle, $chunk ) use ( &$status, &$is_sse, &$error_body, &$stream_tail, &$saw_done ) {
684 if ( connection_aborted() ) {
685 return 0;
686 }
687 // An error response is JSON, not SSE — collect it and report it as
688 // one event once the request finishes, even if it has HTTP 200.
689 if ( $status < 200 || $status >= 300 || ! $is_sse ) {
690 $error_body .= substr( $chunk, 0, max( 0, 16384 - strlen( $error_body ) ) );
691 return strlen( $chunk );
692 }
693 $stream_tail .= $chunk;
694 if ( preg_match( '/(?:^|\n)data:\s*\[DONE\](?:\r?\n|$)/', $stream_tail ) ) {
695 $saw_done = true;
696 }
697 $stream_tail = substr( $stream_tail, -128 );
698 echo $chunk;
699 flush();
700 return connection_aborted() ? 0 : strlen( $chunk );
701 },
702 ) );
703
704 $ok = curl_exec( $handle );
705
706 if ( false === $ok && '' === $error_body ) {
707 $error_body = wp_json_encode( array( 'error' => array( 'message' => curl_error( $handle ) ) ) );
708 }
709 curl_close( $handle );
710
711 if ( connection_aborted() ) {
712 exit;
713 }
714
715 if ( '' === $error_body && ! $saw_done ) {
716 $error_body = wp_json_encode( array( 'error' => array( 'message' => __( 'The model response was interrupted. Please try again.', 'blockenberg' ) ) ) );
717 }
718
719 if ( '' !== $error_body ) {
720 $decoded = json_decode( $error_body, true );
721 $message = is_array( $decoded ) ? bkbg_ai_error_message( $decoded ) : '';
722 if ( '' === $message ) {
723 $message = wp_strip_all_tags( substr( $error_body, 0, 400 ) );
724 }
725 // Separate an error from any incomplete upstream event.
726 echo "\n\n" . 'data: ' . wp_json_encode( array( 'error' => array( 'message' => $message ) ) ) . "\n\n";
727 flush();
728 }
729
730 if ( ! $saw_done ) {
731 echo "data: [DONE]\n\n";
732 }
733 flush();
734 exit;
735 }
736
737 /**
738 * POST to the OpenRouter API and normalise errors.
739 *
740 * @param string $path API path after /api/v1/.
741 * @param array $payload JSON payload.
742 * @param int $timeout Seconds.
743 * @return array|WP_Error
744 */
745 function bkbg_ai_openrouter_post( $path, $payload, $timeout = 120 ) {
746 if ( '' === bkbg_ai_get_api_key() ) {
747 return new WP_Error( 'bkbg_ai_no_key', __( 'No OpenRouter API key is configured. Add one under Blockenberg → AI Agent.', 'blockenberg' ), array( 'status' => 400 ) );
748 }
749
750 $response = wp_remote_post( 'https://openrouter.ai/api/v1/' . ltrim( $path, '/' ), array(
751 'timeout' => $timeout,
752 'headers' => array(
753 'Authorization' => 'Bearer ' . bkbg_ai_get_api_key(),
754 'Content-Type' => 'application/json',
755 'HTTP-Referer' => home_url( '/' ),
756 'X-Title' => 'Blockenberg AI Agent',
757 ),
758 'body' => wp_json_encode( $payload ),
759 ) );
760
761 if ( is_wp_error( $response ) ) {
762 return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) );
763 }
764
765 $code = (int) wp_remote_retrieve_response_code( $response );
766 $body = json_decode( wp_remote_retrieve_body( $response ), true );
767
768 if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) {
769 $message = is_array( $body )
770 ? bkbg_ai_error_message( $body )
771 : wp_strip_all_tags( substr( (string) wp_remote_retrieve_body( $response ), 0, 500 ) );
772
773 if ( '' === trim( (string) $message ) ) {
774 $message = sprintf(
775 /* translators: %d: HTTP status code */
776 __( 'The model provider returned HTTP %d.', 'blockenberg' ),
777 $code
778 );
779 }
780 return new WP_Error( 'bkbg_ai_provider', $message, array( 'status' => 502 ) );
781 }
782
783 // A 200 can still carry an error object (provider-level failure).
784 if ( isset( $body['error'] ) ) {
785 return new WP_Error( 'bkbg_ai_provider', bkbg_ai_error_message( $body ), array( 'status' => 502 ) );
786 }
787
788 return $body;
789 }
790
791 /**
792 * Flatten an OpenRouter error body into one readable line.
793 *
794 * OpenRouter often answers with a vague "Provider returned error" and puts the
795 * real cause in error.metadata, so surface that too.
796 *
797 * @param array $body Decoded response body.
798 * @return string
799 */
800 function bkbg_ai_error_message( $body ) {
801 if ( ! isset( $body['error'] ) ) {
802 return '';
803 }
804
805 $error = $body['error'];
806 if ( ! is_array( $error ) ) {
807 return is_scalar( $error ) ? (string) $error : __( 'The model provider returned an invalid error response.', 'blockenberg' );
808 }
809 $message = isset( $error['message'] ) && is_string( $error['message'] )
810 ? $error['message']
811 : __( 'The model provider could not complete the request.', 'blockenberg' );
812 $parts = array();
813
814 if ( ! empty( $error['metadata']['provider_name'] ) ) {
815 $parts[] = (string) $error['metadata']['provider_name'];
816 }
817
818 if ( ! empty( $error['metadata']['raw'] ) ) {
819 $raw = $error['metadata']['raw'];
820 $parts[] = wp_strip_all_tags( substr( is_string( $raw ) ? $raw : (string) wp_json_encode( $raw ), 0, 400 ) );
821 }
822
823 if ( ! empty( $error['code'] ) && ! $parts ) {
824 $parts[] = 'code ' . $error['code'];
825 }
826
827 return $parts ? $message . '' . implode( ': ', $parts ) : $message;
828 }
829
830 /**
831 * Fetch a public OpenRouter model catalog.
832 *
833 * @param string $url Catalog URL.
834 * @return array|WP_Error Raw model objects.
835 */
836 function bkbg_ai_fetch_openrouter_catalog( $url ) {
837 $response = wp_remote_get( $url, array( 'timeout' => 20 ) );
838 if ( is_wp_error( $response ) ) {
839 return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) );
840 }
841
842 $body = json_decode( wp_remote_retrieve_body( $response ), true );
843 $code = (int) wp_remote_retrieve_response_code( $response );
844 if ( 200 !== $code || ! is_array( $body ) || ! isset( $body['data'] ) || ! is_array( $body['data'] ) || isset( $body['error'] ) ) {
845 $message = is_array( $body ) ? bkbg_ai_error_message( $body ) : '';
846 return new WP_Error(
847 'bkbg_ai_provider',
848 $message ? $message : __( 'The model list is temporarily unavailable. Try again shortly.', 'blockenberg' ),
849 array( 'status' => 502 )
850 );
851 }
852
853 return $body['data'];
854 }
855
856 /**
857 * Normalize one OpenRouter catalog row for the settings pickers.
858 *
859 * @param array $model Provider model object.
860 * @return array|null
861 */
862 function bkbg_ai_normalize_catalog_model( $model ) {
863 if ( ! is_array( $model ) || empty( $model['id'] ) || ! bkbg_ai_valid_model_id( $model['id'] ) ) {
864 return null;
865 }
866 $pricing = array();
867 $prices_known = true;
868 foreach ( isset( $model['pricing'] ) && is_array( $model['pricing'] ) ? $model['pricing'] : array() as $metric => $price ) {
869 if ( is_numeric( $price ) && is_finite( (float) $price ) ) {
870 $pricing[ $metric ] = (float) $price;
871 } else {
872 $prices_known = false;
873 }
874 }
875 // Missing prices and routed prices (-1) are not a promise of free
876 // usage. Every advertised charge must be zero, including requests.
877 $free = $prices_known && isset( $pricing['prompt'], $pricing['completion'] ) &&
878 0.0 === $pricing['prompt'] && 0.0 === $pricing['completion'];
879 foreach ( $pricing as $price ) {
880 if ( 0.0 !== $price ) {
881 $free = false;
882 break;
883 }
884 }
885 return array(
886 'id' => $model['id'],
887 'name' => isset( $model['name'] ) && is_string( $model['name'] ) ? $model['name'] : $model['id'],
888 'context' => isset( $model['context_length'] ) ? (int) $model['context_length'] : 0,
889 'free' => $free,
890 'pricing' => $pricing,
891 );
892 }
893
894 /**
895 * Sort catalog models: free first, then alphabetical.
896 *
897 * @param array $models Normalized models.
898 * @return array
899 */
900 function bkbg_ai_sort_catalog_models( $models ) {
901 usort( $models, function ( $left, $right ) {
902 if ( $left['free'] !== $right['free'] ) {
903 return $left['free'] ? -1 : 1;
904 }
905 $name_order = strcasecmp( $left['name'], $right['name'] );
906 return $name_order ? $name_order : strcmp( $left['id'], $right['id'] );
907 } );
908 return $models;
909 }
910
911 /**
912 * Whether a catalog row advertises image output.
913 *
914 * @param array $model Provider model object.
915 * @return bool
916 */
917 function bkbg_ai_model_outputs_image( $model ) {
918 if ( ! is_array( $model ) || empty( $model['architecture'] ) || ! is_array( $model['architecture'] ) ) {
919 return false;
920 }
921 $outputs = isset( $model['architecture']['output_modalities'] ) ? $model['architecture']['output_modalities'] : array();
922 return is_array( $outputs ) && in_array( 'image', $outputs, true );
923 }
924
925 /**
926 * List OpenRouter chat models that can call tools (cached for an hour).
927 *
928 * @return WP_REST_Response|WP_Error
929 */
930 function bkbg_ai_rest_models() {
931 $cached = get_transient( BKBG_AI_MODELS_CACHE );
932 if ( is_array( $cached ) ) {
933 return rest_ensure_response( $cached );
934 }
935
936 $data = bkbg_ai_fetch_openrouter_catalog( 'https://openrouter.ai/api/v1/models' );
937 if ( is_wp_error( $data ) ) {
938 return $data;
939 }
940
941 $models = array();
942 foreach ( $data as $model ) {
943 $params = isset( $model['supported_parameters'] ) ? (array) $model['supported_parameters'] : array();
944 if ( ! in_array( 'tools', $params, true ) ) {
945 continue;
946 }
947 $normalized = bkbg_ai_normalize_catalog_model( $model );
948 if ( $normalized ) {
949 $models[] = $normalized;
950 }
951 }
952
953 $models = bkbg_ai_sort_catalog_models( $models );
954 set_transient( BKBG_AI_MODELS_CACHE, $models, HOUR_IN_SECONDS );
955 return rest_ensure_response( $models );
956 }
957
958 /**
959 * List OpenRouter image-generation models (cached for an hour).
960 *
961 * @return WP_REST_Response|WP_Error
962 */
963 function bkbg_ai_rest_image_models() {
964 $cached = get_transient( BKBG_AI_IMAGE_MODELS_CACHE );
965 if ( is_array( $cached ) ) {
966 return rest_ensure_response( $cached );
967 }
968
969 $data = bkbg_ai_fetch_openrouter_catalog( 'https://openrouter.ai/api/v1/models?output_modalities=image' );
970 if ( is_wp_error( $data ) ) {
971 return $data;
972 }
973
974 $models = array();
975 foreach ( $data as $model ) {
976 if ( ! bkbg_ai_model_outputs_image( $model ) ) {
977 continue;
978 }
979 $normalized = bkbg_ai_normalize_catalog_model( $model );
980 if ( $normalized ) {
981 $models[] = $normalized;
982 }
983 }
984
985 $models = bkbg_ai_sort_catalog_models( $models );
986 set_transient( BKBG_AI_IMAGE_MODELS_CACHE, $models, HOUR_IN_SECONDS );
987 return rest_ensure_response( $models );
988 }
989
990 /**
991 * Find or generate an image and put it in the Media Library.
992 *
993 * @param WP_REST_Request $request Request.
994 * @return WP_REST_Response|WP_Error
995 */
996 function bkbg_ai_rest_media( WP_REST_Request $request ) {
997 $settings = bkbg_ai_get_settings();
998 $params = $request->get_json_params();
999 if ( ! is_array( $params ) ) {
1000 $params = array();
1001 }
1002
1003 $prompt = isset( $params['prompt'] ) ? sanitize_text_field( $params['prompt'] ) : '';
1004 $alt = isset( $params['alt'] ) ? sanitize_text_field( $params['alt'] ) : $prompt;
1005 $source = isset( $params['source'] ) ? sanitize_text_field( $params['source'] ) : $settings['image_source'];
1006 $post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0;
1007
1008 // Check the parent before making a billed request or adding any media.
1009 if ( $post_id && ( $post_id < 0 || ! get_post( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) ) {
1010 return new WP_Error( 'bkbg_ai_forbidden', __( 'You cannot attach images to this post.', 'blockenberg' ), array( 'status' => 403 ) );
1011 }
1012
1013 if ( ! in_array( $source, array( 'openverse', 'openrouter' ), true ) ) {
1014 return new WP_Error( 'bkbg_ai_bad_request', __( 'Unknown image source.', 'blockenberg' ), array( 'status' => 400 ) );
1015 }
1016
1017 if ( '' === $prompt ) {
1018 return new WP_Error( 'bkbg_ai_bad_request', __( 'An image prompt is required.', 'blockenberg' ), array( 'status' => 400 ) );
1019 }
1020
1021 if ( 'openrouter' === $source ) {
1022 $image = bkbg_ai_generate_image( $prompt, $settings['image_model'] );
1023 } else {
1024 $image = bkbg_ai_search_openverse( $prompt );
1025 }
1026
1027 if ( is_wp_error( $image ) ) {
1028 return $image;
1029 }
1030
1031 $attachment_id = bkbg_ai_sideload_candidates( $image, $prompt, $alt, $post_id );
1032 if ( is_wp_error( $attachment_id ) ) {
1033 return $attachment_id;
1034 }
1035
1036 return rest_ensure_response( array(
1037 'id' => $attachment_id,
1038 'url' => wp_get_attachment_url( $attachment_id ),
1039 'alt' => $alt,
1040 'source' => $source,
1041 'credit' => isset( $image['credit'] ) ? $image['credit'] : '',
1042 'caption' => bkbg_ai_image_caption( $image ),
1043 'attribution' => bkbg_ai_image_attribution( $image ),
1044 ) );
1045 }
1046
1047 /** Try a bounded set of search results, retaining the credit of the saved image. */
1048 function bkbg_ai_sideload_candidates( &$image, $title, $alt, $post_id = 0 ) {
1049 $candidates = array_merge( array( $image ), isset( $image['alternatives'] ) && is_array( $image['alternatives'] ) ? $image['alternatives'] : array() );
1050 $seen = array();
1051 $first_error = null;
1052 foreach ( array_slice( $candidates, 0, 3 ) as $candidate ) {
1053 $url = isset( $candidate['url'] ) ? $candidate['url'] : '';
1054 if ( ! is_string( $url ) || isset( $seen[ $url ] ) ) { continue; }
1055 $seen[ $url ] = true;
1056 $result = bkbg_ai_sideload( $candidate, $title, $alt, $post_id );
1057 if ( ! is_wp_error( $result ) ) {
1058 $image = $candidate;
1059 return $result;
1060 }
1061 if ( ! $first_error ) { $first_error = $result; }
1062 // Retrying another remote file cannot fix a local write/permission error.
1063 if ( in_array( $result->get_error_code(), array( 'bkbg_ai_image_write', 'upload_error' ), true ) ) { break; }
1064 }
1065 return $first_error ?: new WP_Error( 'bkbg_ai_bad_image', __( 'No downloadable image was found.', 'blockenberg' ), array( 'status' => 502 ) );
1066 }
1067
1068 /**
1069 * Ask an OpenRouter image model for a picture.
1070 *
1071 * @param string $prompt Prompt.
1072 * @param string $model Image-capable model id.
1073 * @return array|WP_Error {data|url, mime}
1074 */
1075 function bkbg_ai_generate_image( $prompt, $model ) {
1076 $body = bkbg_ai_openrouter_post( 'images', array(
1077 'model' => $model,
1078 'prompt' => $prompt,
1079 'n' => 1,
1080 ), 180 );
1081
1082 if ( is_wp_error( $body ) ) {
1083 return $body;
1084 }
1085
1086 $url = '';
1087 if ( isset( $body['data'] ) && is_array( $body['data'] ) ) {
1088 foreach ( $body['data'] as $img ) {
1089 if ( ! empty( $img['b64_json'] ) && is_string( $img['b64_json'] ) ) {
1090 // media_type may be omitted; sideload inspects the bytes to
1091 // choose the real extension instead of trusting this label.
1092 $mime = isset( $img['media_type'] ) && is_string( $img['media_type'] ) ? $img['media_type'] : 'image/png';
1093 $url = 'data:' . $mime . ';base64,' . $img['b64_json'];
1094 break;
1095 }
1096 }
1097 }
1098
1099 if ( '' === $url ) {
1100 return new WP_Error(
1101 'bkbg_ai_no_image',
1102 sprintf(
1103 /* translators: %s: model id */
1104 __( 'The model %s did not return an image. Pick an image-capable model under Blockenberg → AI Agent.', 'blockenberg' ),
1105 $model
1106 ),
1107 array( 'status' => 502 )
1108 );
1109 }
1110
1111 return array( 'url' => $url, 'credit' => sprintf( 'Generated with %s', $model ) );
1112 }
1113
1114 /**
1115 * Find an openly licensed photo.
1116 *
1117 * Tries Openverse first, then Wikimedia Commons, and shortens the query as it
1118 * goes — long descriptive prompts rarely match anything, and either service can
1119 * be down without the agent losing the ability to illustrate a page.
1120 *
1121 * @param string $query Search terms.
1122 * @return array|WP_Error
1123 */
1124 function bkbg_ai_search_openverse( $query ) {
1125 $tried = array();
1126 $variants = bkbg_ai_query_variants( $query );
1127 $sources = array( 'bkbg_ai_openverse_request', 'bkbg_ai_commons_request' );
1128
1129 foreach ( $sources as $source ) {
1130 // A source that just timed out is skipped for a few minutes — paying
1131 // that timeout again on every image would stall a whole page build.
1132 $down_key = 'bkbg_ai_down_' . md5( $source );
1133 if ( get_transient( $down_key ) ) {
1134 continue;
1135 }
1136
1137 foreach ( $variants as $variant ) {
1138 $tried[] = $variant;
1139 $result = call_user_func( $source, $variant );
1140
1141 if ( ! is_wp_error( $result ) ) {
1142 return $result;
1143 }
1144
1145 // The service itself is unreachable — remember that and move on to
1146 // the next source rather than burning the timeout again.
1147 if ( 'bkbg_ai_http' === $result->get_error_code() ) {
1148 set_transient( $down_key, 1, 5 * MINUTE_IN_SECONDS );
1149 break;
1150 }
1151 }
1152 }
1153
1154 return new WP_Error(
1155 'bkbg_ai_no_image',
1156 sprintf(
1157 /* translators: %s: semicolon separated search queries */
1158 __( 'No openly licensed image found. Tried: %s. Use two or three plain English nouns, or switch to image generation.', 'blockenberg' ),
1159 implode( '; ', array_unique( $tried ) )
1160 ),
1161 array( 'status' => 404 )
1162 );
1163 }
1164
1165 /**
1166 * Search Wikimedia Commons — the fallback photo source.
1167 *
1168 * Everything hosted on Commons is under a licence that allows commercial use
1169 * and modification, so no licence screening is needed here.
1170 *
1171 * @param string $query Search terms.
1172 * @return array|WP_Error {url, credit}
1173 */
1174 function bkbg_ai_commons_request( $query ) {
1175 $endpoint = add_query_arg( array(
1176 'action' => 'query',
1177 'format' => 'json',
1178 'generator' => 'search',
1179 'gsrsearch' => 'filetype:bitmap ' . $query,
1180 'gsrnamespace' => 6,
1181 'gsrlimit' => 20,
1182 'prop' => 'imageinfo',
1183 'iiprop' => 'url|mime|extmetadata',
1184 'iiurlwidth' => 1600,
1185 ), 'https://commons.wikimedia.org/w/api.php' );
1186
1187 $response = wp_remote_get( $endpoint, array(
1188 'timeout' => 10,
1189 'headers' => array( 'User-Agent' => 'Blockenberg/' . home_url( '/' ) ),
1190 ) );
1191
1192 if ( is_wp_error( $response ) ) {
1193 return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) );
1194 }
1195
1196 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
1197 return new WP_Error( 'bkbg_ai_http', __( 'The image search service is temporarily unavailable.', 'blockenberg' ), array( 'status' => 502 ) );
1198 }
1199
1200 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1201 $pages = isset( $body['query']['pages'] ) ? $body['query']['pages'] : array();
1202
1203 if ( ! is_array( $pages ) || ! $pages ) {
1204 return new WP_Error( 'bkbg_ai_no_image', 'no results', array( 'status' => 404 ) );
1205 }
1206
1207 $usable = array();
1208 foreach ( $pages as $page ) {
1209 $info = isset( $page['imageinfo'][0] ) ? $page['imageinfo'][0] : null;
1210 if ( ! $info ) {
1211 continue;
1212 }
1213 $mime = isset( $info['mime'] ) ? $info['mime'] : '';
1214 if ( 0 !== strpos( $mime, 'image/' ) ) {
1215 continue;
1216 }
1217 $url = ! empty( $info['thumburl'] ) ? $info['thumburl'] : ( ! empty( $info['url'] ) ? $info['url'] : '' );
1218 if ( '' === $url ) {
1219 continue;
1220 }
1221
1222 $meta = isset( $info['extmetadata'] ) ? $info['extmetadata'] : array();
1223 $artist = isset( $meta['Artist']['value'] ) ? wp_strip_all_tags( $meta['Artist']['value'] ) : '';
1224 $license = isset( $meta['LicenseShortName']['value'] ) ? wp_strip_all_tags( $meta['LicenseShortName']['value'] ) : 'Wikimedia Commons';
1225
1226 $usable[] = array(
1227 'url' => $url,
1228 'credit' => trim( $artist ? $artist . ' (' . $license . ')' : $license ),
1229 'title' => isset( $page['title'] ) ? $page['title'] : '',
1230 'creator' => $artist,
1231 'source_url' => isset( $info['descriptionurl'] ) ? $info['descriptionurl'] : '',
1232 'license' => $license,
1233 'license_url' => isset( $meta['LicenseUrl']['value'] ) ? $meta['LicenseUrl']['value'] : '',
1234 );
1235 }
1236
1237 if ( ! $usable ) {
1238 return new WP_Error( 'bkbg_ai_no_image', 'no usable file in results', array( 'status' => 404 ) );
1239 }
1240
1241 return bkbg_ai_rank_images( $usable, $query );
1242 }
1243
1244 /** Rank by title relevance instead of selecting an arbitrary search hit. */
1245 function bkbg_ai_rank_images( $candidates, $query ) {
1246 $lower = function ( $text ) { return function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); };
1247 $words = array_unique( preg_split( '/[^\p{L}\p{N}]+/u', $lower( $query ), -1, PREG_SPLIT_NO_EMPTY ) );
1248 $words = array_values( array_diff( $words, array( 'a', 'an', 'the', 'of', 'in', 'on', 'for', 'and', 'with', 'photo', 'image', 'picture', 'file' ) ) );
1249 $ranked = array();
1250 foreach ( $candidates as $index => $candidate ) {
1251 $title = $lower( isset( $candidate['title'] ) ? $candidate['title'] : '' );
1252 $score = 0;
1253 foreach ( $words as $word ) {
1254 if ( false !== strpos( $title, $word ) ) { $score++; }
1255 }
1256 if ( $score ) { $ranked[] = array( 'score' => $score, 'index' => $index, 'image' => $candidate ); }
1257 }
1258 if ( ! $ranked ) {
1259 return new WP_Error( 'bkbg_ai_no_image', 'No image title matched the subject. Try two or three concrete subject keywords.', array( 'status' => 404 ) );
1260 }
1261 usort( $ranked, function ( $a, $b ) { return ( $b['score'] <=> $a['score'] ) ?: ( $a['index'] <=> $b['index'] ); } );
1262 $best = $ranked[0]['score'];
1263 $shortlist = array_values( array_filter( $ranked, function ( $row ) use ( $best ) { return $row['score'] === $best; } ) );
1264 // Vary only among equally relevant results; never sacrifice the subject.
1265 $offset = wp_rand( 0, count( $shortlist ) - 1 );
1266 $shortlist = array_merge( array_slice( $shortlist, $offset ), array_slice( $shortlist, 0, $offset ) );
1267 $images = array_column( array_slice( $shortlist, 0, 3 ), 'image' );
1268 $image = array_shift( $images );
1269 if ( $images ) { $image['alternatives'] = $images; }
1270 return $image;
1271 }
1272
1273 /**
1274 * Progressively shorter versions of a search query.
1275 *
1276 * @param string $query Raw query.
1277 * @return array Query variants, longest first.
1278 */
1279 function bkbg_ai_query_variants( $query ) {
1280 $clean = preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', (string) $query );
1281 $words = preg_split( '/\s+/u', trim( (string) $clean ), -1, PREG_SPLIT_NO_EMPTY );
1282
1283 if ( ! $words ) {
1284 return array( trim( (string) $query ) );
1285 }
1286
1287 $variants = array( implode( ' ', $words ) );
1288
1289 if ( count( $words ) > 4 ) {
1290 $variants[] = implode( ' ', array_slice( $words, 0, 4 ) );
1291 }
1292 if ( count( $words ) > 2 ) {
1293 $variants[] = implode( ' ', array_slice( $words, 0, 2 ) );
1294 }
1295
1296 return array_values( array_unique( array_filter( $variants ) ) );
1297 }
1298
1299 /**
1300 * Licences that allow commercial use and modification — the only ones safe to
1301 * drop onto a client's page.
1302 *
1303 * @return array
1304 */
1305 function bkbg_ai_allowed_licenses() {
1306 return array( 'cc0', 'pdm', 'by', 'by-sa' );
1307 }
1308
1309 /**
1310 * One Openverse query.
1311 *
1312 * The API's own license_type filter returns nothing for multi-word queries, so
1313 * ask unfiltered and screen the licences here instead.
1314 *
1315 * @param string $query Search terms.
1316 * @return array|WP_Error {url, credit}
1317 */
1318 function bkbg_ai_openverse_request( $query ) {
1319 $endpoint = add_query_arg( array(
1320 'q' => $query,
1321 'page_size' => 20,
1322 'mature' => 'false',
1323 ), 'https://api.openverse.org/v1/images/' );
1324
1325 // Fail fast: a slow photo service must not hold up the whole agent run.
1326 $response = wp_remote_get( $endpoint, array(
1327 'timeout' => 10,
1328 'headers' => array( 'User-Agent' => 'Blockenberg/' . home_url( '/' ) ),
1329 ) );
1330
1331 if ( is_wp_error( $response ) ) {
1332 return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) );
1333 }
1334
1335 if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
1336 return new WP_Error( 'bkbg_ai_http', __( 'The image search service is temporarily unavailable.', 'blockenberg' ), array( 'status' => 502 ) );
1337 }
1338
1339 $body = json_decode( wp_remote_retrieve_body( $response ), true );
1340 if ( empty( $body['results'] ) || ! is_array( $body['results'] ) ) {
1341 return new WP_Error( 'bkbg_ai_no_image', 'no results', array( 'status' => 404 ) );
1342 }
1343
1344 $allowed = bkbg_ai_allowed_licenses();
1345 $usable = array();
1346
1347 foreach ( $body['results'] as $result ) {
1348 $license = isset( $result['license'] ) ? strtolower( $result['license'] ) : '';
1349 if ( ! in_array( $license, $allowed, true ) ) {
1350 continue;
1351 }
1352 if ( empty( $result['url'] ) && empty( $result['thumbnail'] ) ) {
1353 continue;
1354 }
1355 $usable[] = $result;
1356 }
1357
1358 if ( ! $usable ) {
1359 return new WP_Error( 'bkbg_ai_no_image', 'no commercially usable licence in results', array( 'status' => 404 ) );
1360 }
1361
1362 $candidates = array();
1363 foreach ( $usable as $choice ) {
1364 $candidates[] = array(
1365 'url' => ! empty( $choice['url'] ) ? $choice['url'] : $choice['thumbnail'],
1366 'title' => isset( $choice['title'] ) ? $choice['title'] : '',
1367 'credit' => ! empty( $choice['creator'] ) ? sprintf( '%s (%s)', $choice['creator'], strtoupper( $choice['license'] ) ) : strtoupper( $choice['license'] ) . ' via Openverse',
1368 'creator' => isset( $choice['creator'] ) ? $choice['creator'] : '',
1369 'creator_url' => isset( $choice['creator_url'] ) ? $choice['creator_url'] : '',
1370 'source_url' => isset( $choice['foreign_landing_url'] ) ? $choice['foreign_landing_url'] : '',
1371 'license' => strtoupper( $choice['license'] ) . ( ! empty( $choice['license_version'] ) ? ' ' . $choice['license_version'] : '' ),
1372 'license_url' => isset( $choice['license_url'] ) ? $choice['license_url'] : '',
1373 );
1374 }
1375 return bkbg_ai_rank_images( $candidates, $query );
1376 }
1377
1378 /** Keep source data reusable without trusting markup returned by image providers. */
1379 function bkbg_ai_image_attribution( $image ) {
1380 $result = array();
1381 foreach ( array( 'title', 'creator', 'license' ) as $key ) {
1382 $result[ $key ] = isset( $image[ $key ] ) ? sanitize_text_field( $image[ $key ] ) : '';
1383 }
1384 foreach ( array( 'creator_url', 'source_url', 'license_url' ) as $key ) {
1385 $result[ $key ] = isset( $image[ $key ] ) ? esc_url_raw( $image[ $key ], array( 'http', 'https' ) ) : '';
1386 }
1387 return $result;
1388 }
1389
1390 /** A ready-to-use caption for native image captions or an adjacent credit line. */
1391 function bkbg_ai_image_caption( $image ) {
1392 $data = bkbg_ai_image_attribution( $image );
1393 if ( ! $data['license'] && ! $data['source_url'] ) { return ''; }
1394 $parts = array();
1395 foreach ( array( 'title' => 'source_url', 'creator' => 'creator_url', 'license' => 'license_url' ) as $label => $url ) {
1396 $text = $data[ $label ];
1397 if ( ! $text && 'title' === $label && $data[ $url ] ) { $text = __( 'Image source', 'blockenberg' ); }
1398 if ( ! $text ) { continue; }
1399 $parts[] = $data[ $url ]
1400 ? '<a href="' . esc_url( $data[ $url ] ) . '">' . esc_html( $text ) . '</a>'
1401 : esc_html( $text );
1402 }
1403 return implode( ' · ', $parts );
1404 }
1405
1406 /**
1407 * Save a remote or data-URL image into the Media Library.
1408 *
1409 * @param array $image {url, credit}.
1410 * @param string $title Attachment title.
1411 * @param string $alt Alt text.
1412 * @param int $post_id Parent post.
1413 * @return int|WP_Error Attachment id.
1414 */
1415 function bkbg_ai_sideload( $image, $title, $alt, $post_id = 0 ) {
1416 require_once ABSPATH . 'wp-admin/includes/file.php';
1417 require_once ABSPATH . 'wp-admin/includes/media.php';
1418 require_once ABSPATH . 'wp-admin/includes/image.php';
1419
1420 $url = isset( $image['url'] ) && is_string( $image['url'] ) ? $image['url'] : '';
1421 if ( '' === $url ) {
1422 return new WP_Error( 'bkbg_ai_bad_image', __( 'Invalid image URL.', 'blockenberg' ), array( 'status' => 400 ) );
1423 }
1424 $max_bytes = min( 20 * MB_IN_BYTES, wp_max_upload_size() );
1425 $tmp = '';
1426
1427 if ( 0 === strpos( $url, 'data:' ) ) {
1428 // Bound the base64 input before decoding a second copy into memory.
1429 if ( strlen( $url ) > (int) ceil( $max_bytes / 3 ) * 4 + 100 ) {
1430 return new WP_Error( 'bkbg_ai_image_too_large', __( 'The image exceeds the upload size limit.', 'blockenberg' ), array( 'status' => 413 ) );
1431 }
1432 // data:image/png;base64,….
1433 if ( ! preg_match( '#^data:image/([a-z0-9.+-]+);base64,(.+)$#is', $url, $m ) ) {
1434 return new WP_Error( 'bkbg_ai_bad_image', __( 'The generated image could not be decoded.', 'blockenberg' ), array( 'status' => 502 ) );
1435 }
1436 $data = base64_decode( $m[2], true );
1437 if ( false === $data || '' === $data ) {
1438 return new WP_Error( 'bkbg_ai_bad_image', __( 'The generated image could not be decoded.', 'blockenberg' ), array( 'status' => 502 ) );
1439 }
1440 $tmp = wp_tempnam( 'bkbg-ai-image' );
1441 if ( ! $tmp || strlen( $data ) !== file_put_contents( $tmp, $data ) ) {
1442 if ( $tmp ) {
1443 @unlink( $tmp );
1444 }
1445 return new WP_Error( 'bkbg_ai_image_write', __( 'The image could not be saved to a temporary file.', 'blockenberg' ), array( 'status' => 500 ) );
1446 }
1447 unset( $data );
1448 } else {
1449 $url = esc_url_raw( $url, array( 'http', 'https' ) );
1450 if ( ! $url ) {
1451 return new WP_Error( 'bkbg_ai_bad_image', __( 'Invalid image URL.', 'blockenberg' ), array( 'status' => 400 ) );
1452 }
1453 if ( ! wp_http_validate_url( $url ) ) {
1454 return new WP_Error(
1455 'bkbg_ai_image_url_blocked',
1456 sprintf(
1457 /* translators: %s: remote image hostname */
1458 __( 'WordPress blocked the image download from %s because the address could not be validated as public. Check the server DNS/VPN configuration (including fake-IP mode), or use image generation instead.', 'blockenberg' ),
1459 (string) wp_parse_url( $url, PHP_URL_HOST )
1460 ),
1461 array( 'status' => 502 )
1462 );
1463 }
1464 $tmp = wp_tempnam( 'bkbg-ai-image' );
1465 if ( ! $tmp ) {
1466 return new WP_Error( 'bkbg_ai_image_write', __( 'The image could not be saved to a temporary file.', 'blockenberg' ), array( 'status' => 500 ) );
1467 }
1468 // Safe HTTP validates redirects and rejects private-network URLs. A
1469 // bounded streamed download also prevents filling the server's disk.
1470 $response = wp_safe_remote_get( $url, array(
1471 'timeout' => 30,
1472 'stream' => true,
1473 'filename' => $tmp,
1474 'limit_response_size' => $max_bytes + 1,
1475 ) );
1476 if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
1477 @unlink( $tmp );
1478 return is_wp_error( $response )
1479 ? $response
1480 : new WP_Error( 'bkbg_ai_bad_image', __( 'The image could not be downloaded.', 'blockenberg' ), array( 'status' => 502 ) );
1481 }
1482 }
1483
1484 if ( filesize( $tmp ) > $max_bytes ) {
1485 @unlink( $tmp );
1486 return new WP_Error( 'bkbg_ai_image_too_large', __( 'The image exceeds the upload size limit.', 'blockenberg' ), array( 'status' => 413 ) );
1487 }
1488
1489 // URLs may be extensionless or lie about the format. Only accept real
1490 // raster image bytes and name them accordingly, including generated data.
1491 $extensions = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/avif' => 'avif' );
1492 $mime = wp_get_image_mime( $tmp );
1493 if ( ! $mime || ! isset( $extensions[ $mime ] ) ) {
1494 @unlink( $tmp );
1495 return new WP_Error( 'bkbg_ai_bad_image', __( 'The response did not contain a supported image.', 'blockenberg' ), array( 'status' => 502 ) );
1496 }
1497 $filename = substr( sanitize_title( $title ? $title : 'ai-image' ), 0, 160 ) . '.' . $extensions[ $mime ];
1498
1499 $file_array = array(
1500 'name' => $filename,
1501 'tmp_name' => $tmp,
1502 );
1503
1504 $attachment_id = media_handle_sideload( $file_array, $post_id, $title, array( 'post_excerpt' => bkbg_ai_image_caption( $image ) ) );
1505
1506 if ( is_wp_error( $attachment_id ) ) {
1507 @unlink( $tmp );
1508 return $attachment_id;
1509 }
1510
1511 if ( $alt ) {
1512 update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt );
1513 }
1514 if ( ! empty( $image['credit'] ) ) {
1515 update_post_meta( $attachment_id, '_bkbg_ai_image_credit', sanitize_text_field( $image['credit'] ) );
1516 }
1517 update_post_meta( $attachment_id, '_bkbg_ai_image_attribution', bkbg_ai_image_attribution( $image ) );
1518
1519 return (int) $attachment_id;
1520 }
1521
1522 /* ──────────────────────────────────────────────
1523 * 5. Nesting rules — which blocks accept inner blocks
1524 * ────────────────────────────────────────────── */
1525
1526 /**
1527 * Scan block sources once and remember which blocks use InnerBlocks and what
1528 * they allow inside. Cached per plugin version.
1529 *
1530 * @return array name => array( 'allowed' => string[]|null )
1531 */
1532 function bkbg_ai_inner_blocks_map() {
1533 $plugin_file = dirname( __DIR__, 2 ) . '/blockenberg.php';
1534 $cache_key = 'bkbg_ai_inner_map_' . md5( (string) @filemtime( $plugin_file ) );
1535 $cached = get_transient( $cache_key );
1536 if ( is_array( $cached ) ) {
1537 return $cached;
1538 }
1539
1540 $map = array();
1541 $blocks_dir = dirname( __DIR__, 2 ) . '/blocks';
1542
1543 foreach ( glob( $blocks_dir . '/*/index.js' ) as $file ) {
1544 $source = file_get_contents( $file );
1545 if ( ! $source || false === strpos( $source, 'InnerBlocks' ) ) {
1546 continue;
1547 }
1548 $slug = basename( dirname( $file ) );
1549 $allowed = array();
1550 // Matches both `var allowedBlocks = [...]` and `allowedBlocks: [...]`.
1551 if ( preg_match_all( '/allowedBlocks\s*[:=]\s*\[([^\]]*)\]/', $source, $matches ) ) {
1552 foreach ( $matches[1] as $list ) {
1553 preg_match_all( "/['\"]([a-z0-9-]+\/[a-z0-9-]+)['\"]/i", $list, $names );
1554 if ( ! empty( $names[1] ) ) {
1555 $allowed = array_merge( $allowed, $names[1] );
1556 }
1557 }
1558 }
1559 $allowed = $allowed ? array_values( array_unique( $allowed ) ) : null;
1560 $map[ 'blockenberg/' . $slug ] = array( 'allowed' => $allowed );
1561 }
1562
1563 set_transient( $cache_key, $map, WEEK_IN_SECONDS );
1564 return $map;
1565 }
1566
1567 /* ──────────────────────────────────────────────
1568 * 6. Editor assets
1569 * ────────────────────────────────────────────── */
1570
1571 add_action( 'enqueue_block_editor_assets', function () {
1572 if ( ! bkbg_ai_user_can_use() ) {
1573 return;
1574 }
1575
1576 $plugin_dir = dirname( __DIR__, 2 );
1577 $plugin_url = plugins_url( '', $plugin_dir . '/blockenberg.php' );
1578
1579 $js = $plugin_dir . '/assets/js/ai-assistant.js';
1580 $css = $plugin_dir . '/assets/css/ai-assistant.css';
1581
1582 if ( ! file_exists( $js ) ) {
1583 return;
1584 }
1585
1586 wp_enqueue_script(
1587 'bkbg-ai-assistant',
1588 $plugin_url . '/assets/js/ai-assistant.js',
1589 array(
1590 'wp-plugins',
1591 'wp-element',
1592 'wp-components',
1593 'wp-data',
1594 'wp-blocks',
1595 'wp-block-editor',
1596 'wp-editor',
1597 'wp-i18n',
1598 'wp-api-fetch',
1599 'wp-compose',
1600 'wp-notices',
1601 'wp-dom-ready',
1602 'wp-keyboard-shortcuts',
1603 'bkbg-ai-external',
1604 ),
1605 filemtime( $js ),
1606 true
1607 );
1608
1609 wp_enqueue_script( 'bkbg-html-to-image', $plugin_url . '/assets/js/vendor/html-to-image-1.11.13.js', array(), '1.11.13', true );
1610 wp_enqueue_script( 'bkbg-ai-capture', $plugin_url . '/assets/js/ai-capture.js', array( 'bkbg-html-to-image' ), filemtime( $plugin_dir . '/assets/js/ai-capture.js' ), true );
1611 wp_enqueue_script( 'bkbg-ai-external', $plugin_url . '/assets/js/ai-external.js', array( 'wp-element', 'wp-components', 'wp-data', 'wp-i18n', 'bkbg-ai-capture' ), filemtime( $plugin_dir . '/assets/js/ai-external.js' ), true );
1612
1613 if ( file_exists( $css ) ) {
1614 wp_enqueue_style(
1615 'bkbg-ai-assistant',
1616 $plugin_url . '/assets/css/ai-assistant.css',
1617 array( 'wp-components' ),
1618 filemtime( $css )
1619 );
1620 }
1621
1622 $settings = bkbg_ai_get_settings();
1623
1624 // wp_localize_script() casts everything to strings, which would turn
1625 // booleans and numbers into truthy strings — encode the config instead.
1626 $config = array(
1627 'restBase' => esc_url_raw( rest_url( 'blockenberg/v1' ) ),
1628 'nonce' => wp_create_nonce( 'wp_rest' ),
1629 'configured' => '' !== bkbg_ai_get_api_key(),
1630 'canManage' => current_user_can( 'manage_options' ),
1631 'canUpload' => current_user_can( 'upload_files' ),
1632 'settingsUrl' => admin_url( 'admin.php?page=blockenberg-ai' ),
1633 'model' => $settings['model'],
1634 'preferredModel' => bkbg_ai_get_preferred_model(),
1635 'userId' => get_current_user_id(),
1636 'conversations' => bkbg_chat_read( get_current_user_id(), bkbg_ai_editor_post_id() ),
1637 'siteId' => get_current_blog_id(),
1638 'imageSource' => $settings['image_source'],
1639 'maxSteps' => (int) $settings['max_steps'],
1640 'stream' => ! empty( $settings['stream'] ) && function_exists( 'curl_init' ),
1641 'temperature' => (float) $settings['temperature'],
1642 'siteContext' => $settings['site_context'],
1643 'siteName' => get_bloginfo( 'name' ),
1644 'cssMetaKey' => BKBG_AI_CSS_META,
1645 'innerBlocks' => bkbg_ai_inner_blocks_map(),
1646 'openSidebar' => ! empty( $settings['open_sidebar'] ),
1647 );
1648
1649 wp_add_inline_script(
1650 'bkbg-ai-assistant',
1651 'window.bkbgAI = ' . wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) . ';',
1652 'before'
1653 );
1654 wp_add_inline_script( 'bkbg-ai-external', 'window.bkbgAI = ' . wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) . ';', 'before' );
1655
1656 wp_set_script_translations( 'bkbg-ai-assistant', 'blockenberg' );
1657 wp_set_script_translations( 'bkbg-ai-external', 'blockenberg' );
1658 } );
1659
1660 /**
1661 * Custom HTML has no save wrapper, so constrained layouts box it like a paragraph.
1662 * Mark the output full-width so AI sections (and other HTML blocks) span the page.
1663 */
1664 add_filter( 'register_block_type_args', function ( $args, $block_type ) {
1665 if ( 'core/html' !== $block_type ) {
1666 return $args;
1667 }
1668 if ( ! isset( $args['supports'] ) || ! is_array( $args['supports'] ) ) {
1669 $args['supports'] = array();
1670 }
1671 $args['supports']['align'] = array( 'wide', 'full' );
1672 return $args;
1673 }, 10, 2 );
1674
1675 add_filter( 'render_block_core/html', function ( $content ) {
1676 $trimmed = ltrim( (string) $content );
1677 if ( '' === $trimmed ) {
1678 return $content;
1679 }
1680 if ( preg_match( '/^<[a-zA-Z][^>]*\balignfull\b/', $trimmed ) ) {
1681 return $content;
1682 }
1683 return '<div class="wp-block-html alignfull">' . $content . '</div>';
1684 } );
1685
1686 add_action( 'wp_enqueue_scripts', function () {
1687 $css = '.is-layout-constrained > .wp-block-html.alignfull,'
1688 . '.is-layout-constrained > section.alignfull,'
1689 . '.is-layout-constrained > header.alignfull,'
1690 . '.is-layout-constrained > footer.alignfull,'
1691 . '.is-layout-constrained > article.alignfull {'
1692 . 'max-width: none;'
1693 . '}'
1694 . '.wp-block-html.alignfull,'
1695 . '.is-layout-constrained > section.alignfull,'
1696 . '.is-layout-constrained > header.alignfull,'
1697 . '.is-layout-constrained > footer.alignfull,'
1698 . '.is-layout-constrained > article.alignfull {'
1699 . 'box-sizing: border-box;'
1700 . 'overflow-x: clip;'
1701 . '}'
1702 . '.wp-block-html.alignfull > :where(section, div, header, footer, article, main, aside),'
1703 . '.is-layout-constrained > section.alignfull,'
1704 . '.is-layout-constrained > header.alignfull,'
1705 . '.is-layout-constrained > footer.alignfull,'
1706 . '.is-layout-constrained > article.alignfull {'
1707 . 'box-sizing: border-box !important;'
1708 . 'width: 100% !important;'
1709 . 'max-width: 100% !important;'
1710 . 'margin-left: 0 !important;'
1711 . 'margin-right: 0 !important;'
1712 . 'left: auto !important;'
1713 . 'transform: none !important;'
1714 . '}'
1715 . '.wp-block-html.alignfull img,'
1716 . '.wp-block-html.alignfull video,'
1717 . '.wp-block-html.alignfull iframe,'
1718 . '.is-layout-constrained > section.alignfull img,'
1719 . '.is-layout-constrained > section.alignfull video,'
1720 . '.is-layout-constrained > section.alignfull iframe {'
1721 . 'max-width: 100%;'
1722 . 'height: auto;'
1723 . '}';
1724 wp_register_style( 'bkbg-html-sections', false, array(), '1' );
1725 wp_enqueue_style( 'bkbg-html-sections' );
1726 wp_add_inline_style( 'bkbg-html-sections', $css );
1727 } );
1728