| 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 |
|