'', 'model' => 'anthropic/claude-sonnet-4.5', 'image_model' => 'google/gemini-2.5-flash-image', 'image_source' => 'openverse', // openverse | openrouter 'stream' => true, 'max_steps' => 0, // Zero disables the step limit. 'temperature' => 0.4, 'site_context' => '', 'open_sidebar' => true, ); } /** * Get the AI settings merged with defaults. * * @return array */ function bkbg_ai_get_settings() { $saved = get_option( BKBG_AI_OPTION, array() ); if ( ! is_array( $saved ) ) { $saved = array(); } return array_merge( bkbg_ai_default_settings(), $saved ); } /** * The OpenRouter API key, if configured. * * Also honours the BLOCKENBERG_AI_API_KEY constant (wp-config.php) so the key * can be kept out of the database entirely. * * @return string */ function bkbg_ai_get_api_key() { if ( defined( 'BLOCKENBERG_AI_API_KEY' ) && BLOCKENBERG_AI_API_KEY ) { return (string) BLOCKENBERG_AI_API_KEY; } $settings = bkbg_ai_get_settings(); return (string) $settings['api_key']; } /** * Who may talk to the agent. * * @return bool */ function bkbg_ai_user_can_use() { /** * Capability required to use the AI agent. Every request spends credits * on the site's OpenRouter account, so a site can tighten this. * * @param string $capability Capability name. */ $capability = apply_filters( 'blockenberg_ai_capability', 'edit_posts' ); return current_user_can( $capability ); } /** Post being edited in the block editor, including when the main query is empty. */ function bkbg_ai_editor_post_id() { $post_id = (int) get_the_ID(); if ( $post_id ) { return $post_id; } foreach ( array( 'post', 'post_id', 'postId' ) as $key ) { if ( ! isset( $_GET[ $key ] ) ) { continue; } $value = wp_unslash( $_GET[ $key ] ); if ( is_numeric( $value ) ) { return absint( $value ); } } return 0; } /** Model identifiers may contain provider prefixes and routing suffixes. */ function bkbg_ai_valid_model_id( $model ) { return is_string( $model ) && 1 === preg_match( '#^[a-z0-9][a-z0-9._:/+@-]{0,199}$#i', $model ); } /** Read the current user's last selection without exposing account settings. */ function bkbg_ai_get_preferred_model() { $model = get_user_meta( get_current_user_id(), BKBG_AI_MODEL_META, true ); return bkbg_ai_valid_model_id( $model ) ? $model : ''; } /** Validate form settings while keeping saved secrets and disabled fields. */ function bkbg_ai_sanitize_settings( $posted, $current ) { $settings = $current; $key = isset( $posted['api_key'] ) ? trim( sanitize_text_field( $posted['api_key'] ) ) : ''; if ( ! defined( 'BLOCKENBERG_AI_API_KEY' ) || ! BLOCKENBERG_AI_API_KEY ) { if ( ! empty( $posted['remove_api_key'] ) ) { $settings['api_key'] = ''; } elseif ( '' !== $key && ! preg_match( '/^[\x{2022}\s]+$/u', $key ) ) { $settings['api_key'] = $key; } } foreach ( array( 'model', 'image_model' ) as $field ) { $model = isset( $posted[ $field ] ) ? sanitize_text_field( $posted[ $field ] ) : ''; if ( bkbg_ai_valid_model_id( $model ) ) { $settings[ $field ] = $model; } } if ( isset( $posted['image_source'] ) && in_array( $posted['image_source'], array( 'openverse', 'openrouter' ), true ) ) { $settings['image_source'] = $posted['image_source']; } $settings['stream'] = ! empty( $posted['stream'] ); if ( isset( $posted['max_steps'] ) && is_numeric( $posted['max_steps'] ) ) { $settings['max_steps'] = max( 0, min( 40, (int) $posted['max_steps'] ) ); } if ( isset( $posted['temperature'] ) && is_numeric( $posted['temperature'] ) ) { $settings['temperature'] = max( 0, min( 2, (float) $posted['temperature'] ) ); } if ( isset( $posted['site_context'] ) ) { $settings['site_context'] = sanitize_textarea_field( $posted['site_context'] ); } $settings['open_sidebar'] = ! empty( $posted['open_sidebar'] ); return $settings; } /* ────────────────────────────────────────────── * 2. Settings screen (Blockenberg → AI Agent Settings) * ────────────────────────────────────────────── */ add_action( 'admin_menu', function () { add_submenu_page( 'blockenberg', __( 'AI Agent Settings', 'blockenberg' ), __( 'AI Agent Settings', 'blockenberg' ), 'manage_options', 'blockenberg-ai', 'bkbg_ai_render_settings_page' ); }, 20 ); /** * Save handler for the settings form. */ add_action( 'admin_post_bkbg_ai_save_settings', function () { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to do this.', 'blockenberg' ), '', array( 'response' => 403 ) ); } check_admin_referer( 'bkbg_ai_settings' ); $settings = bkbg_ai_sanitize_settings( wp_unslash( $_POST ), bkbg_ai_get_settings() ); update_option( BKBG_AI_OPTION, $settings, false ); wp_safe_redirect( add_query_arg( 'bkbg-updated', '1', admin_url( 'admin.php?page=blockenberg-ai' ) ) ); exit; } ); /** * AJAX — verify the key by asking OpenRouter who we are. */ add_action( 'wp_ajax_bkbg_ai_test_key', function () { check_ajax_referer( 'bkbg_ai_test', 'nonce' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Unauthorized', 'blockenberg' ) ), 403 ); } // Test what is in the field right now, so a key can be verified before it is // saved. An untouched masked field falls back to the stored key. $posted = isset( $_POST['api_key'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['api_key'] ) ) ) : ''; $stored = bkbg_ai_get_api_key(); $is_mask = (bool) preg_match( '/^[\x{2022}\s]+$/u', $posted ); $unsaved = ( '' !== $posted && ! $is_mask && $posted !== $stored ); $key = $unsaved ? $posted : $stored; if ( '' === $key ) { wp_send_json_error( array( 'message' => __( 'Enter an API key first.', 'blockenberg' ) ) ); } $response = wp_remote_get( 'https://openrouter.ai/api/v1/key', array( 'timeout' => 20, 'headers' => array( 'Authorization' => 'Bearer ' . $key ), ) ); if ( is_wp_error( $response ) ) { wp_send_json_error( array( 'message' => $response->get_error_message() ) ); } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== (int) $code ) { $msg = isset( $body['error']['message'] ) ? $body['error']['message'] : sprintf( 'HTTP %d', $code ); wp_send_json_error( array( 'message' => $msg ) ); } $label = __( 'Key is valid.', 'blockenberg' ); if ( isset( $body['data']['limit_remaining'] ) && null !== $body['data']['limit_remaining'] ) { $label .= ' ' . sprintf( /* translators: %s: remaining credit */ __( 'Remaining credit: %s', 'blockenberg' ), '$' . number_format_i18n( (float) $body['data']['limit_remaining'], 2 ) ); } if ( $unsaved ) { $label .= ' ' . __( 'Save the settings to start using it.', 'blockenberg' ); } wp_send_json_success( array( 'message' => $label, 'unsaved' => $unsaved ) ); } ); /** * Render the settings screen. */ function bkbg_ai_render_settings_page() { $s = bkbg_ai_get_settings(); $const = defined( 'BLOCKENBERG_AI_API_KEY' ) && BLOCKENBERG_AI_API_KEY; $has_key = '' !== bkbg_ai_get_api_key(); ?>

Blockenberg

esc_url_raw( rest_url( 'blockenberg/v1' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'testNonce' => wp_create_nonce( 'bkbg_ai_test' ), 'configured' => '' !== bkbg_ai_get_api_key(), ) ) . ';', 'before' ); wp_set_script_translations( 'bkbg-ai-settings', 'blockenberg' ); } ); /* ────────────────────────────────────────────── * 3. Custom CSS written by the agent * ────────────────────────────────────────────── */ add_action( 'init', function () { register_post_meta( '', BKBG_AI_CSS_META, array( 'type' => 'string', 'single' => true, 'default' => '', 'show_in_rest' => true, 'sanitize_callback' => 'bkbg_ai_sanitize_css', 'auth_callback' => function ( $allowed, $meta_key, $post_id ) { return current_user_can( 'edit_post', $post_id ); }, ) ); } ); /** * Keep CSS as CSS: no tags, no closing style element. * * @param string $css Raw CSS. * @return string */ function bkbg_ai_sanitize_css( $css ) { $css = (string) $css; $css = preg_replace( '#]*>#i', '', $css ); // A literal '<' cannot occur in an HTML raw-text style element safely. // Keep '>', however: it is the CSS child combinator and is also valid in // media queries. Removing it silently changes the rules the user sees. $css = str_replace( '<', '', $css ); return trim( wp_check_invalid_utf8( $css ) ); } /** * Print per-post custom CSS on the front end. */ add_action( 'wp_head', function () { if ( ! is_singular() ) { return; } $css = get_post_meta( get_queried_object_id(), BKBG_AI_CSS_META, true ); if ( ! $css ) { return; } echo "\n\n"; }, 99 ); /* ────────────────────────────────────────────── * 4. REST API * ────────────────────────────────────────────── */ add_action( 'rest_api_init', function () { $permission = function () { return bkbg_ai_user_can_use() ? true : new WP_Error( 'bkbg_ai_forbidden', __( 'You cannot use the AI agent.', 'blockenberg' ), array( 'status' => 403 ) ); }; register_rest_route( 'blockenberg/v1', '/ai/chat', array( 'methods' => 'POST', 'permission_callback' => $permission, 'callback' => 'bkbg_ai_rest_chat', ) ); register_rest_route( 'blockenberg/v1', '/ai/models', array( 'methods' => 'GET', 'permission_callback' => $permission, 'callback' => 'bkbg_ai_rest_models', ) ); register_rest_route( 'blockenberg/v1', '/ai/image-models', array( 'methods' => 'GET', 'permission_callback' => $permission, 'callback' => 'bkbg_ai_rest_image_models', ) ); register_rest_route( 'blockenberg/v1', '/ai/preferences', array( array( 'methods' => 'GET', 'permission_callback' => $permission, 'callback' => function () { return rest_ensure_response( array( 'model' => bkbg_ai_get_preferred_model() ) ); }, ), array( 'methods' => 'POST', 'permission_callback' => $permission, 'callback' => 'bkbg_ai_rest_save_preferences', ), ) ); register_rest_route( 'blockenberg/v1', '/ai/media', array( 'methods' => 'POST', 'permission_callback' => function () { return bkbg_ai_user_can_use() && current_user_can( 'upload_files' ) ? true : new WP_Error( 'bkbg_ai_forbidden', __( 'You cannot create images with the AI agent.', 'blockenberg' ), array( 'status' => 403 ) ); }, 'callback' => 'bkbg_ai_rest_media', ) ); } ); /** Save only this user's model choice; this endpoint cannot alter site settings. */ function bkbg_ai_rest_save_preferences( WP_REST_Request $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) || ! isset( $params['model'] ) || ( '' !== $params['model'] && ! bkbg_ai_valid_model_id( $params['model'] ) ) ) { return new WP_Error( 'bkbg_ai_bad_request', __( 'Choose a valid model.', 'blockenberg' ), array( 'status' => 400 ) ); } $model = $params['model']; $user_id = get_current_user_id(); if ( '' === $model ) { delete_user_meta( $user_id, BKBG_AI_MODEL_META ); } else { update_user_meta( $user_id, BKBG_AI_MODEL_META, $model ); } if ( bkbg_ai_get_preferred_model() !== $model ) { return new WP_Error( 'bkbg_ai_preference_save', __( 'The model preference could not be saved. Please try again.', 'blockenberg' ), array( 'status' => 500 ) ); } return rest_ensure_response( array( 'model' => $model ) ); } /** * Proxy a single chat completion to OpenRouter. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function bkbg_ai_rest_chat( WP_REST_Request $request ) { $key = bkbg_ai_get_api_key(); if ( '' === $key ) { return new WP_Error( 'bkbg_ai_no_key', __( 'No OpenRouter API key is configured. Add one under Blockenberg → AI Agent Settings.', 'blockenberg' ), array( 'status' => 400 ) ); } $settings = bkbg_ai_get_settings(); $params = $request->get_json_params(); if ( ! is_array( $params ) ) { $params = array(); } $messages = isset( $params['messages'] ) ? $params['messages'] : null; if ( ! is_array( $messages ) || empty( $messages ) ) { return new WP_Error( 'bkbg_ai_bad_request', __( 'No messages supplied.', 'blockenberg' ), array( 'status' => 400 ) ); } // Conversations grow with every tool result; keep a sane ceiling. if ( strlen( (string) wp_json_encode( $messages ) ) > 2000000 ) { return new WP_Error( 'bkbg_ai_too_large', __( 'This conversation has grown too large. Clear the chat and start a fresh one.', 'blockenberg' ), array( 'status' => 413 ) ); } $payload = array( 'model' => isset( $params['model'] ) && $params['model'] ? sanitize_text_field( $params['model'] ) : $settings['model'], 'messages' => $messages, ); if ( isset( $params['tools'] ) && is_array( $params['tools'] ) ) { $payload['tools'] = $params['tools']; $payload['tool_choice'] = isset( $params['tool_choice'] ) ? $params['tool_choice'] : 'auto'; } $payload['temperature'] = isset( $params['temperature'] ) ? max( 0, min( 2, (float) $params['temperature'] ) ) : (float) $settings['temperature']; if ( isset( $params['max_tokens'] ) ) { $payload['max_tokens'] = max( 256, min( 32000, (int) $params['max_tokens'] ) ); } // Ask OpenRouter to report what the call cost. if ( ! empty( $params['usage']['include'] ) ) { $payload['usage'] = array( 'include' => true ); } if ( ! empty( $params['stream'] ) && ! empty( $settings['stream'] ) && function_exists( 'curl_init' ) ) { bkbg_ai_stream_completion( $payload ); // Writes SSE and exits. } $response = bkbg_ai_openrouter_post( 'chat/completions', $payload, 180 ); if ( is_wp_error( $response ) ) { return $response; } return rest_ensure_response( $response ); } /** * Pipe a streaming completion straight through to the browser as SSE. * * Never returns: the response is written by hand so chunks reach the panel as * the model writes them. * * @param array $payload Chat completion payload. * @return void */ function bkbg_ai_stream_completion( $payload ) { $payload['stream'] = true; // Turn off everything that would hold the bytes back. @ini_set( 'zlib.output_compression', '0' ); @ini_set( 'implicit_flush', '1' ); @ini_set( 'max_execution_time', '300' ); while ( ob_get_level() > 0 ) { if ( ! @ob_end_flush() ) { break; // Some hosting providers install non-removable buffers. } } @ob_implicit_flush( true ); // Let the cURL callback close the upstream connection when the user stops // the reply, including on hosts configured to ignore aborted requests. ignore_user_abort( true ); nocache_headers(); header( 'Content-Type: text/event-stream; charset=utf-8' ); header( 'Cache-Control: no-cache, no-store, must-revalidate' ); header( 'X-Accel-Buffering: no' ); // nginx header( 'Connection: keep-alive' ); $status = 0; $is_sse = false; $stream_tail = ''; $saw_done = false; $error_body = ''; $handle = curl_init( 'https://openrouter.ai/api/v1/chat/completions' ); curl_setopt_array( $handle, array( CURLOPT_POST => true, CURLOPT_POSTFIELDS => wp_json_encode( $payload ), CURLOPT_RETURNTRANSFER => false, CURLOPT_CONNECTTIMEOUT => 20, CURLOPT_TIMEOUT => 300, CURLOPT_CAINFO => ABSPATH . WPINC . '/certificates/ca-bundle.crt', CURLOPT_HTTPHEADER => array( 'Authorization: Bearer ' . bkbg_ai_get_api_key(), 'Content-Type: application/json', 'Accept: text/event-stream', 'HTTP-Referer: ' . home_url( '/' ), 'X-Title: Blockenberg AI Agent', ), CURLOPT_HEADERFUNCTION => function ( $handle, $header ) use ( &$status, &$is_sse ) { if ( preg_match( '#^HTTP/\S+\s+(\d{3})#', $header, $match ) ) { $status = (int) $match[1]; $is_sse = false; } elseif ( 0 === stripos( $header, 'Content-Type:' ) ) { $is_sse = false !== stripos( $header, 'text/event-stream' ); } return strlen( $header ); }, CURLOPT_WRITEFUNCTION => function ( $handle, $chunk ) use ( &$status, &$is_sse, &$error_body, &$stream_tail, &$saw_done ) { if ( connection_aborted() ) { return 0; } // An error response is JSON, not SSE — collect it and report it as // one event once the request finishes, even if it has HTTP 200. if ( $status < 200 || $status >= 300 || ! $is_sse ) { $error_body .= substr( $chunk, 0, max( 0, 16384 - strlen( $error_body ) ) ); return strlen( $chunk ); } $stream_tail .= $chunk; if ( preg_match( '/(?:^|\n)data:\s*\[DONE\](?:\r?\n|$)/', $stream_tail ) ) { $saw_done = true; } $stream_tail = substr( $stream_tail, -128 ); echo $chunk; flush(); return connection_aborted() ? 0 : strlen( $chunk ); }, ) ); $ok = curl_exec( $handle ); if ( false === $ok && '' === $error_body ) { $error_body = wp_json_encode( array( 'error' => array( 'message' => curl_error( $handle ) ) ) ); } curl_close( $handle ); if ( connection_aborted() ) { exit; } if ( '' === $error_body && ! $saw_done ) { $error_body = wp_json_encode( array( 'error' => array( 'message' => __( 'The model response was interrupted. Please try again.', 'blockenberg' ) ) ) ); } if ( '' !== $error_body ) { $decoded = json_decode( $error_body, true ); $message = is_array( $decoded ) ? bkbg_ai_error_message( $decoded ) : ''; if ( '' === $message ) { $message = wp_strip_all_tags( substr( $error_body, 0, 400 ) ); } // Separate an error from any incomplete upstream event. echo "\n\n" . 'data: ' . wp_json_encode( array( 'error' => array( 'message' => $message ) ) ) . "\n\n"; flush(); } if ( ! $saw_done ) { echo "data: [DONE]\n\n"; } flush(); exit; } /** * POST to the OpenRouter API and normalise errors. * * @param string $path API path after /api/v1/. * @param array $payload JSON payload. * @param int $timeout Seconds. * @return array|WP_Error */ function bkbg_ai_openrouter_post( $path, $payload, $timeout = 120 ) { if ( '' === bkbg_ai_get_api_key() ) { return new WP_Error( 'bkbg_ai_no_key', __( 'No OpenRouter API key is configured. Add one under Blockenberg → AI Agent Settings.', 'blockenberg' ), array( 'status' => 400 ) ); } $response = wp_remote_post( 'https://openrouter.ai/api/v1/' . ltrim( $path, '/' ), array( 'timeout' => $timeout, 'headers' => array( 'Authorization' => 'Bearer ' . bkbg_ai_get_api_key(), 'Content-Type' => 'application/json', 'HTTP-Referer' => home_url( '/' ), 'X-Title' => 'Blockenberg AI Agent', ), 'body' => wp_json_encode( $payload ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 || ! is_array( $body ) ) { $message = is_array( $body ) ? bkbg_ai_error_message( $body ) : wp_strip_all_tags( substr( (string) wp_remote_retrieve_body( $response ), 0, 500 ) ); if ( '' === trim( (string) $message ) ) { $message = sprintf( /* translators: %d: HTTP status code */ __( 'The model provider returned HTTP %d.', 'blockenberg' ), $code ); } return new WP_Error( 'bkbg_ai_provider', $message, array( 'status' => 502 ) ); } // A 200 can still carry an error object (provider-level failure). if ( isset( $body['error'] ) ) { return new WP_Error( 'bkbg_ai_provider', bkbg_ai_error_message( $body ), array( 'status' => 502 ) ); } return $body; } /** * Flatten an OpenRouter error body into one readable line. * * OpenRouter often answers with a vague "Provider returned error" and puts the * real cause in error.metadata, so surface that too. * * @param array $body Decoded response body. * @return string */ function bkbg_ai_error_message( $body ) { if ( ! isset( $body['error'] ) ) { return ''; } $error = $body['error']; if ( ! is_array( $error ) ) { return is_scalar( $error ) ? (string) $error : __( 'The model provider returned an invalid error response.', 'blockenberg' ); } $message = isset( $error['message'] ) && is_string( $error['message'] ) ? $error['message'] : __( 'The model provider could not complete the request.', 'blockenberg' ); $parts = array(); if ( ! empty( $error['metadata']['provider_name'] ) ) { $parts[] = (string) $error['metadata']['provider_name']; } if ( ! empty( $error['metadata']['raw'] ) ) { $raw = $error['metadata']['raw']; $parts[] = wp_strip_all_tags( substr( is_string( $raw ) ? $raw : (string) wp_json_encode( $raw ), 0, 400 ) ); } if ( ! empty( $error['code'] ) && ! $parts ) { $parts[] = 'code ' . $error['code']; } return $parts ? $message . ' — ' . implode( ': ', $parts ) : $message; } /** * Fetch a public OpenRouter model catalog. * * @param string $url Catalog URL. * @return array|WP_Error Raw model objects. */ function bkbg_ai_fetch_openrouter_catalog( $url ) { $response = wp_remote_get( $url, array( 'timeout' => 20 ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $code = (int) wp_remote_retrieve_response_code( $response ); if ( 200 !== $code || ! is_array( $body ) || ! isset( $body['data'] ) || ! is_array( $body['data'] ) || isset( $body['error'] ) ) { $message = is_array( $body ) ? bkbg_ai_error_message( $body ) : ''; return new WP_Error( 'bkbg_ai_provider', $message ? $message : __( 'The model list is temporarily unavailable. Try again shortly.', 'blockenberg' ), array( 'status' => 502 ) ); } return $body['data']; } /** * Normalize one OpenRouter catalog row for the settings pickers. * * @param array $model Provider model object. * @return array|null */ function bkbg_ai_normalize_catalog_model( $model ) { if ( ! is_array( $model ) || empty( $model['id'] ) || ! bkbg_ai_valid_model_id( $model['id'] ) ) { return null; } $pricing = array(); $prices_known = true; foreach ( isset( $model['pricing'] ) && is_array( $model['pricing'] ) ? $model['pricing'] : array() as $metric => $price ) { if ( is_numeric( $price ) && is_finite( (float) $price ) ) { $pricing[ $metric ] = (float) $price; } else { $prices_known = false; } } // Missing prices and routed prices (-1) are not a promise of free // usage. Every advertised charge must be zero, including requests. $free = $prices_known && isset( $pricing['prompt'], $pricing['completion'] ) && 0.0 === $pricing['prompt'] && 0.0 === $pricing['completion']; foreach ( $pricing as $price ) { if ( 0.0 !== $price ) { $free = false; break; } } return array( 'id' => $model['id'], 'name' => isset( $model['name'] ) && is_string( $model['name'] ) ? $model['name'] : $model['id'], 'context' => isset( $model['context_length'] ) ? (int) $model['context_length'] : 0, 'free' => $free, 'pricing' => $pricing, ); } /** * Sort catalog models: free first, then alphabetical. * * @param array $models Normalized models. * @return array */ function bkbg_ai_sort_catalog_models( $models ) { usort( $models, function ( $left, $right ) { if ( $left['free'] !== $right['free'] ) { return $left['free'] ? -1 : 1; } $name_order = strcasecmp( $left['name'], $right['name'] ); return $name_order ? $name_order : strcmp( $left['id'], $right['id'] ); } ); return $models; } /** * Whether a catalog row advertises image output. * * @param array $model Provider model object. * @return bool */ function bkbg_ai_model_outputs_image( $model ) { if ( ! is_array( $model ) || empty( $model['architecture'] ) || ! is_array( $model['architecture'] ) ) { return false; } $outputs = isset( $model['architecture']['output_modalities'] ) ? $model['architecture']['output_modalities'] : array(); return is_array( $outputs ) && in_array( 'image', $outputs, true ); } /** * List OpenRouter chat models that can call tools (cached for an hour). * * @return WP_REST_Response|WP_Error */ function bkbg_ai_rest_models() { $cached = get_transient( BKBG_AI_MODELS_CACHE ); if ( is_array( $cached ) ) { return rest_ensure_response( $cached ); } $data = bkbg_ai_fetch_openrouter_catalog( 'https://openrouter.ai/api/v1/models' ); if ( is_wp_error( $data ) ) { return $data; } $models = array(); foreach ( $data as $model ) { $params = isset( $model['supported_parameters'] ) ? (array) $model['supported_parameters'] : array(); if ( ! in_array( 'tools', $params, true ) ) { continue; } $normalized = bkbg_ai_normalize_catalog_model( $model ); if ( $normalized ) { $models[] = $normalized; } } $models = bkbg_ai_sort_catalog_models( $models ); set_transient( BKBG_AI_MODELS_CACHE, $models, HOUR_IN_SECONDS ); return rest_ensure_response( $models ); } /** * List OpenRouter image-generation models (cached for an hour). * * @return WP_REST_Response|WP_Error */ function bkbg_ai_rest_image_models() { $cached = get_transient( BKBG_AI_IMAGE_MODELS_CACHE ); if ( is_array( $cached ) ) { return rest_ensure_response( $cached ); } $data = bkbg_ai_fetch_openrouter_catalog( 'https://openrouter.ai/api/v1/models?output_modalities=image' ); if ( is_wp_error( $data ) ) { return $data; } $models = array(); foreach ( $data as $model ) { if ( ! bkbg_ai_model_outputs_image( $model ) ) { continue; } $normalized = bkbg_ai_normalize_catalog_model( $model ); if ( $normalized ) { $models[] = $normalized; } } $models = bkbg_ai_sort_catalog_models( $models ); set_transient( BKBG_AI_IMAGE_MODELS_CACHE, $models, HOUR_IN_SECONDS ); return rest_ensure_response( $models ); } /** * Find or generate an image and put it in the Media Library. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function bkbg_ai_rest_media( WP_REST_Request $request ) { $settings = bkbg_ai_get_settings(); $params = $request->get_json_params(); if ( ! is_array( $params ) ) { $params = array(); } $prompt = isset( $params['prompt'] ) ? sanitize_text_field( $params['prompt'] ) : ''; $alt = isset( $params['alt'] ) ? sanitize_text_field( $params['alt'] ) : $prompt; $source = isset( $params['source'] ) ? sanitize_text_field( $params['source'] ) : $settings['image_source']; $post_id = isset( $params['post_id'] ) ? (int) $params['post_id'] : 0; // Check the parent before making a billed request or adding any media. if ( $post_id && ( $post_id < 0 || ! get_post( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) ) { return new WP_Error( 'bkbg_ai_forbidden', __( 'You cannot attach images to this post.', 'blockenberg' ), array( 'status' => 403 ) ); } if ( ! in_array( $source, array( 'openverse', 'openrouter' ), true ) ) { return new WP_Error( 'bkbg_ai_bad_request', __( 'Unknown image source.', 'blockenberg' ), array( 'status' => 400 ) ); } if ( '' === $prompt ) { return new WP_Error( 'bkbg_ai_bad_request', __( 'An image prompt is required.', 'blockenberg' ), array( 'status' => 400 ) ); } if ( 'openrouter' === $source ) { $image = bkbg_ai_generate_image( $prompt, $settings['image_model'] ); } else { $image = bkbg_ai_search_openverse( $prompt ); } if ( is_wp_error( $image ) ) { return $image; } $attachment_id = bkbg_ai_sideload_candidates( $image, $prompt, $alt, $post_id ); if ( is_wp_error( $attachment_id ) ) { return $attachment_id; } return rest_ensure_response( array( 'id' => $attachment_id, 'url' => wp_get_attachment_url( $attachment_id ), 'alt' => $alt, 'source' => $source, 'credit' => isset( $image['credit'] ) ? $image['credit'] : '', 'caption' => bkbg_ai_image_caption( $image ), 'attribution' => bkbg_ai_image_attribution( $image ), ) ); } /** Try a bounded set of search results, retaining the credit of the saved image. */ function bkbg_ai_sideload_candidates( &$image, $title, $alt, $post_id = 0 ) { $candidates = array_merge( array( $image ), isset( $image['alternatives'] ) && is_array( $image['alternatives'] ) ? $image['alternatives'] : array() ); $seen = array(); $first_error = null; foreach ( array_slice( $candidates, 0, 3 ) as $candidate ) { $url = isset( $candidate['url'] ) ? $candidate['url'] : ''; if ( ! is_string( $url ) || isset( $seen[ $url ] ) ) { continue; } $seen[ $url ] = true; $result = bkbg_ai_sideload( $candidate, $title, $alt, $post_id ); if ( ! is_wp_error( $result ) ) { $image = $candidate; return $result; } if ( ! $first_error ) { $first_error = $result; } // Retrying another remote file cannot fix a local write/permission error. if ( in_array( $result->get_error_code(), array( 'bkbg_ai_image_write', 'upload_error' ), true ) ) { break; } } return $first_error ?: new WP_Error( 'bkbg_ai_bad_image', __( 'No downloadable image was found.', 'blockenberg' ), array( 'status' => 502 ) ); } /** * Ask an OpenRouter image model for a picture. * * @param string $prompt Prompt. * @param string $model Image-capable model id. * @return array|WP_Error {data|url, mime} */ function bkbg_ai_generate_image( $prompt, $model ) { $body = bkbg_ai_openrouter_post( 'images', array( 'model' => $model, 'prompt' => $prompt, 'n' => 1, ), 180 ); if ( is_wp_error( $body ) ) { return $body; } $url = ''; if ( isset( $body['data'] ) && is_array( $body['data'] ) ) { foreach ( $body['data'] as $img ) { if ( ! empty( $img['b64_json'] ) && is_string( $img['b64_json'] ) ) { // media_type may be omitted; sideload inspects the bytes to // choose the real extension instead of trusting this label. $mime = isset( $img['media_type'] ) && is_string( $img['media_type'] ) ? $img['media_type'] : 'image/png'; $url = 'data:' . $mime . ';base64,' . $img['b64_json']; break; } } } if ( '' === $url ) { return new WP_Error( 'bkbg_ai_no_image', sprintf( /* translators: %s: model id */ __( 'The model %s did not return an image. Pick an image-capable model under Blockenberg → AI Agent Settings.', 'blockenberg' ), $model ), array( 'status' => 502 ) ); } return array( 'url' => $url, 'credit' => sprintf( 'Generated with %s', $model ) ); } /** * Find an openly licensed photo. * * Tries Openverse first, then Wikimedia Commons, and shortens the query as it * goes — long descriptive prompts rarely match anything, and either service can * be down without the agent losing the ability to illustrate a page. * * @param string $query Search terms. * @return array|WP_Error */ function bkbg_ai_search_openverse( $query ) { $tried = array(); $variants = bkbg_ai_query_variants( $query ); $sources = array( 'bkbg_ai_openverse_request', 'bkbg_ai_commons_request' ); foreach ( $sources as $source ) { // A source that just timed out is skipped for a few minutes — paying // that timeout again on every image would stall a whole page build. $down_key = 'bkbg_ai_down_' . md5( $source ); if ( get_transient( $down_key ) ) { continue; } foreach ( $variants as $variant ) { $tried[] = $variant; $result = call_user_func( $source, $variant ); if ( ! is_wp_error( $result ) ) { return $result; } // The service itself is unreachable — remember that and move on to // the next source rather than burning the timeout again. if ( 'bkbg_ai_http' === $result->get_error_code() ) { set_transient( $down_key, 1, 5 * MINUTE_IN_SECONDS ); break; } } } return new WP_Error( 'bkbg_ai_no_image', sprintf( /* translators: %s: semicolon separated search queries */ __( 'No openly licensed image found. Tried: %s. Use two or three plain English nouns, or switch to image generation.', 'blockenberg' ), implode( '; ', array_unique( $tried ) ) ), array( 'status' => 404 ) ); } /** * Search Wikimedia Commons — the fallback photo source. * * Everything hosted on Commons is under a licence that allows commercial use * and modification, so no licence screening is needed here. * * @param string $query Search terms. * @return array|WP_Error {url, credit} */ function bkbg_ai_commons_request( $query ) { $endpoint = add_query_arg( array( 'action' => 'query', 'format' => 'json', 'generator' => 'search', 'gsrsearch' => 'filetype:bitmap ' . $query, 'gsrnamespace' => 6, 'gsrlimit' => 20, 'prop' => 'imageinfo', 'iiprop' => 'url|mime|extmetadata', 'iiurlwidth' => 1600, ), 'https://commons.wikimedia.org/w/api.php' ); $response = wp_remote_get( $endpoint, array( 'timeout' => 10, 'headers' => array( 'User-Agent' => 'Blockenberg/' . home_url( '/' ) ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) ); } if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return new WP_Error( 'bkbg_ai_http', __( 'The image search service is temporarily unavailable.', 'blockenberg' ), array( 'status' => 502 ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); $pages = isset( $body['query']['pages'] ) ? $body['query']['pages'] : array(); if ( ! is_array( $pages ) || ! $pages ) { return new WP_Error( 'bkbg_ai_no_image', 'no results', array( 'status' => 404 ) ); } $usable = array(); foreach ( $pages as $page ) { $info = isset( $page['imageinfo'][0] ) ? $page['imageinfo'][0] : null; if ( ! $info ) { continue; } $mime = isset( $info['mime'] ) ? $info['mime'] : ''; if ( 0 !== strpos( $mime, 'image/' ) ) { continue; } $url = ! empty( $info['thumburl'] ) ? $info['thumburl'] : ( ! empty( $info['url'] ) ? $info['url'] : '' ); if ( '' === $url ) { continue; } $meta = isset( $info['extmetadata'] ) ? $info['extmetadata'] : array(); $artist = isset( $meta['Artist']['value'] ) ? wp_strip_all_tags( $meta['Artist']['value'] ) : ''; $license = isset( $meta['LicenseShortName']['value'] ) ? wp_strip_all_tags( $meta['LicenseShortName']['value'] ) : 'Wikimedia Commons'; $usable[] = array( 'url' => $url, 'credit' => trim( $artist ? $artist . ' (' . $license . ')' : $license ), 'title' => isset( $page['title'] ) ? $page['title'] : '', 'creator' => $artist, 'source_url' => isset( $info['descriptionurl'] ) ? $info['descriptionurl'] : '', 'license' => $license, 'license_url' => isset( $meta['LicenseUrl']['value'] ) ? $meta['LicenseUrl']['value'] : '', ); } if ( ! $usable ) { return new WP_Error( 'bkbg_ai_no_image', 'no usable file in results', array( 'status' => 404 ) ); } return bkbg_ai_rank_images( $usable, $query ); } /** Rank by title relevance instead of selecting an arbitrary search hit. */ function bkbg_ai_rank_images( $candidates, $query ) { $lower = function ( $text ) { return function_exists( 'mb_strtolower' ) ? mb_strtolower( $text, 'UTF-8' ) : strtolower( $text ); }; $words = array_unique( preg_split( '/[^\p{L}\p{N}]+/u', $lower( $query ), -1, PREG_SPLIT_NO_EMPTY ) ); $words = array_values( array_diff( $words, array( 'a', 'an', 'the', 'of', 'in', 'on', 'for', 'and', 'with', 'photo', 'image', 'picture', 'file' ) ) ); $ranked = array(); foreach ( $candidates as $index => $candidate ) { $title = $lower( isset( $candidate['title'] ) ? $candidate['title'] : '' ); $score = 0; foreach ( $words as $word ) { if ( false !== strpos( $title, $word ) ) { $score++; } } if ( $score ) { $ranked[] = array( 'score' => $score, 'index' => $index, 'image' => $candidate ); } } if ( ! $ranked ) { return new WP_Error( 'bkbg_ai_no_image', 'No image title matched the subject. Try two or three concrete subject keywords.', array( 'status' => 404 ) ); } usort( $ranked, function ( $a, $b ) { return ( $b['score'] <=> $a['score'] ) ?: ( $a['index'] <=> $b['index'] ); } ); $best = $ranked[0]['score']; $shortlist = array_values( array_filter( $ranked, function ( $row ) use ( $best ) { return $row['score'] === $best; } ) ); // Vary only among equally relevant results; never sacrifice the subject. $offset = wp_rand( 0, count( $shortlist ) - 1 ); $shortlist = array_merge( array_slice( $shortlist, $offset ), array_slice( $shortlist, 0, $offset ) ); $images = array_column( array_slice( $shortlist, 0, 3 ), 'image' ); $image = array_shift( $images ); if ( $images ) { $image['alternatives'] = $images; } return $image; } /** * Progressively shorter versions of a search query. * * @param string $query Raw query. * @return array Query variants, longest first. */ function bkbg_ai_query_variants( $query ) { $clean = preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', (string) $query ); $words = preg_split( '/\s+/u', trim( (string) $clean ), -1, PREG_SPLIT_NO_EMPTY ); if ( ! $words ) { return array( trim( (string) $query ) ); } $variants = array( implode( ' ', $words ) ); if ( count( $words ) > 4 ) { $variants[] = implode( ' ', array_slice( $words, 0, 4 ) ); } if ( count( $words ) > 2 ) { $variants[] = implode( ' ', array_slice( $words, 0, 2 ) ); } return array_values( array_unique( array_filter( $variants ) ) ); } /** * Licences that allow commercial use and modification — the only ones safe to * drop onto a client's page. * * @return array */ function bkbg_ai_allowed_licenses() { return array( 'cc0', 'pdm', 'by', 'by-sa' ); } /** * One Openverse query. * * The API's own license_type filter returns nothing for multi-word queries, so * ask unfiltered and screen the licences here instead. * * @param string $query Search terms. * @return array|WP_Error {url, credit} */ function bkbg_ai_openverse_request( $query ) { $endpoint = add_query_arg( array( 'q' => $query, 'page_size' => 20, 'mature' => 'false', ), 'https://api.openverse.org/v1/images/' ); // Fail fast: a slow photo service must not hold up the whole agent run. $response = wp_remote_get( $endpoint, array( 'timeout' => 10, 'headers' => array( 'User-Agent' => 'Blockenberg/' . home_url( '/' ) ), ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'bkbg_ai_http', $response->get_error_message(), array( 'status' => 502 ) ); } if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { return new WP_Error( 'bkbg_ai_http', __( 'The image search service is temporarily unavailable.', 'blockenberg' ), array( 'status' => 502 ) ); } $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( empty( $body['results'] ) || ! is_array( $body['results'] ) ) { return new WP_Error( 'bkbg_ai_no_image', 'no results', array( 'status' => 404 ) ); } $allowed = bkbg_ai_allowed_licenses(); $usable = array(); foreach ( $body['results'] as $result ) { $license = isset( $result['license'] ) ? strtolower( $result['license'] ) : ''; if ( ! in_array( $license, $allowed, true ) ) { continue; } if ( empty( $result['url'] ) && empty( $result['thumbnail'] ) ) { continue; } $usable[] = $result; } if ( ! $usable ) { return new WP_Error( 'bkbg_ai_no_image', 'no commercially usable licence in results', array( 'status' => 404 ) ); } $candidates = array(); foreach ( $usable as $choice ) { $candidates[] = array( 'url' => ! empty( $choice['url'] ) ? $choice['url'] : $choice['thumbnail'], 'title' => isset( $choice['title'] ) ? $choice['title'] : '', 'credit' => ! empty( $choice['creator'] ) ? sprintf( '%s (%s)', $choice['creator'], strtoupper( $choice['license'] ) ) : strtoupper( $choice['license'] ) . ' via Openverse', 'creator' => isset( $choice['creator'] ) ? $choice['creator'] : '', 'creator_url' => isset( $choice['creator_url'] ) ? $choice['creator_url'] : '', 'source_url' => isset( $choice['foreign_landing_url'] ) ? $choice['foreign_landing_url'] : '', 'license' => strtoupper( $choice['license'] ) . ( ! empty( $choice['license_version'] ) ? ' ' . $choice['license_version'] : '' ), 'license_url' => isset( $choice['license_url'] ) ? $choice['license_url'] : '', ); } return bkbg_ai_rank_images( $candidates, $query ); } /** Keep source data reusable without trusting markup returned by image providers. */ function bkbg_ai_image_attribution( $image ) { $result = array(); foreach ( array( 'title', 'creator', 'license' ) as $key ) { $result[ $key ] = isset( $image[ $key ] ) ? sanitize_text_field( $image[ $key ] ) : ''; } foreach ( array( 'creator_url', 'source_url', 'license_url' ) as $key ) { $result[ $key ] = isset( $image[ $key ] ) ? esc_url_raw( $image[ $key ], array( 'http', 'https' ) ) : ''; } return $result; } /** A ready-to-use caption for native image captions or an adjacent credit line. */ function bkbg_ai_image_caption( $image ) { $data = bkbg_ai_image_attribution( $image ); if ( ! $data['license'] && ! $data['source_url'] ) { return ''; } $parts = array(); foreach ( array( 'title' => 'source_url', 'creator' => 'creator_url', 'license' => 'license_url' ) as $label => $url ) { $text = $data[ $label ]; if ( ! $text && 'title' === $label && $data[ $url ] ) { $text = __( 'Image source', 'blockenberg' ); } if ( ! $text ) { continue; } $parts[] = $data[ $url ] ? '' . esc_html( $text ) . '' : esc_html( $text ); } return implode( ' · ', $parts ); } /** * Save a remote or data-URL image into the Media Library. * * @param array $image {url, credit}. * @param string $title Attachment title. * @param string $alt Alt text. * @param int $post_id Parent post. * @return int|WP_Error Attachment id. */ function bkbg_ai_sideload( $image, $title, $alt, $post_id = 0 ) { require_once ABSPATH . 'wp-admin/includes/file.php'; require_once ABSPATH . 'wp-admin/includes/media.php'; require_once ABSPATH . 'wp-admin/includes/image.php'; $url = isset( $image['url'] ) && is_string( $image['url'] ) ? $image['url'] : ''; if ( '' === $url ) { return new WP_Error( 'bkbg_ai_bad_image', __( 'Invalid image URL.', 'blockenberg' ), array( 'status' => 400 ) ); } $max_bytes = min( 20 * MB_IN_BYTES, wp_max_upload_size() ); $tmp = ''; if ( 0 === strpos( $url, 'data:' ) ) { // Bound the base64 input before decoding a second copy into memory. if ( strlen( $url ) > (int) ceil( $max_bytes / 3 ) * 4 + 100 ) { return new WP_Error( 'bkbg_ai_image_too_large', __( 'The image exceeds the upload size limit.', 'blockenberg' ), array( 'status' => 413 ) ); } // data:image/png;base64,…. if ( ! preg_match( '#^data:image/([a-z0-9.+-]+);base64,(.+)$#is', $url, $m ) ) { return new WP_Error( 'bkbg_ai_bad_image', __( 'The generated image could not be decoded.', 'blockenberg' ), array( 'status' => 502 ) ); } $data = base64_decode( $m[2], true ); if ( false === $data || '' === $data ) { return new WP_Error( 'bkbg_ai_bad_image', __( 'The generated image could not be decoded.', 'blockenberg' ), array( 'status' => 502 ) ); } $tmp = wp_tempnam( 'bkbg-ai-image' ); if ( ! $tmp || strlen( $data ) !== file_put_contents( $tmp, $data ) ) { if ( $tmp ) { @unlink( $tmp ); } return new WP_Error( 'bkbg_ai_image_write', __( 'The image could not be saved to a temporary file.', 'blockenberg' ), array( 'status' => 500 ) ); } unset( $data ); } else { $url = esc_url_raw( $url, array( 'http', 'https' ) ); if ( ! $url ) { return new WP_Error( 'bkbg_ai_bad_image', __( 'Invalid image URL.', 'blockenberg' ), array( 'status' => 400 ) ); } if ( ! wp_http_validate_url( $url ) ) { return new WP_Error( 'bkbg_ai_image_url_blocked', sprintf( /* translators: %s: remote image hostname */ __( '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' ), (string) wp_parse_url( $url, PHP_URL_HOST ) ), array( 'status' => 502 ) ); } $tmp = wp_tempnam( 'bkbg-ai-image' ); if ( ! $tmp ) { return new WP_Error( 'bkbg_ai_image_write', __( 'The image could not be saved to a temporary file.', 'blockenberg' ), array( 'status' => 500 ) ); } // Safe HTTP validates redirects and rejects private-network URLs. A // bounded streamed download also prevents filling the server's disk. $response = wp_safe_remote_get( $url, array( 'timeout' => 30, 'stream' => true, 'filename' => $tmp, 'limit_response_size' => $max_bytes + 1, ) ); if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { @unlink( $tmp ); return is_wp_error( $response ) ? $response : new WP_Error( 'bkbg_ai_bad_image', __( 'The image could not be downloaded.', 'blockenberg' ), array( 'status' => 502 ) ); } } if ( filesize( $tmp ) > $max_bytes ) { @unlink( $tmp ); return new WP_Error( 'bkbg_ai_image_too_large', __( 'The image exceeds the upload size limit.', 'blockenberg' ), array( 'status' => 413 ) ); } // URLs may be extensionless or lie about the format. Only accept real // raster image bytes and name them accordingly, including generated data. $extensions = array( 'image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', 'image/avif' => 'avif' ); $mime = wp_get_image_mime( $tmp ); if ( ! $mime || ! isset( $extensions[ $mime ] ) ) { @unlink( $tmp ); return new WP_Error( 'bkbg_ai_bad_image', __( 'The response did not contain a supported image.', 'blockenberg' ), array( 'status' => 502 ) ); } $filename = substr( sanitize_title( $title ? $title : 'ai-image' ), 0, 160 ) . '.' . $extensions[ $mime ]; $file_array = array( 'name' => $filename, 'tmp_name' => $tmp, ); $attachment_id = media_handle_sideload( $file_array, $post_id, $title, array( 'post_excerpt' => bkbg_ai_image_caption( $image ) ) ); if ( is_wp_error( $attachment_id ) ) { @unlink( $tmp ); return $attachment_id; } if ( $alt ) { update_post_meta( $attachment_id, '_wp_attachment_image_alt', $alt ); } if ( ! empty( $image['credit'] ) ) { update_post_meta( $attachment_id, '_bkbg_ai_image_credit', sanitize_text_field( $image['credit'] ) ); } update_post_meta( $attachment_id, '_bkbg_ai_image_attribution', bkbg_ai_image_attribution( $image ) ); return (int) $attachment_id; } /* ────────────────────────────────────────────── * 5. Nesting rules — which blocks accept inner blocks * ────────────────────────────────────────────── */ /** * Scan block sources once and remember which blocks use InnerBlocks and what * they allow inside. Cached per plugin version. * * @return array name => array( 'allowed' => string[]|null ) */ function bkbg_ai_inner_blocks_map() { $plugin_file = dirname( __DIR__, 2 ) . '/blockenberg.php'; $cache_key = 'bkbg_ai_inner_map_' . md5( (string) @filemtime( $plugin_file ) ); $cached = get_transient( $cache_key ); if ( is_array( $cached ) ) { return $cached; } $map = array(); $blocks_dir = dirname( __DIR__, 2 ) . '/blocks'; foreach ( glob( $blocks_dir . '/*/index.js' ) as $file ) { $source = file_get_contents( $file ); if ( ! $source || false === strpos( $source, 'InnerBlocks' ) ) { continue; } $slug = basename( dirname( $file ) ); $allowed = array(); // Matches both `var allowedBlocks = [...]` and `allowedBlocks: [...]`. if ( preg_match_all( '/allowedBlocks\s*[:=]\s*\[([^\]]*)\]/', $source, $matches ) ) { foreach ( $matches[1] as $list ) { preg_match_all( "/['\"]([a-z0-9-]+\/[a-z0-9-]+)['\"]/i", $list, $names ); if ( ! empty( $names[1] ) ) { $allowed = array_merge( $allowed, $names[1] ); } } } $allowed = $allowed ? array_values( array_unique( $allowed ) ) : null; $map[ 'blockenberg/' . $slug ] = array( 'allowed' => $allowed ); } set_transient( $cache_key, $map, WEEK_IN_SECONDS ); return $map; } /* ────────────────────────────────────────────── * 6. Editor assets * ────────────────────────────────────────────── */ add_action( 'enqueue_block_editor_assets', function () { if ( ! bkbg_ai_user_can_use() ) { return; } $plugin_dir = dirname( __DIR__, 2 ); $plugin_url = plugins_url( '', $plugin_dir . '/blockenberg.php' ); $js = $plugin_dir . '/assets/js/ai-assistant.js'; $css = $plugin_dir . '/assets/css/ai-assistant.css'; if ( ! file_exists( $js ) ) { return; } wp_enqueue_script( 'bkbg-ai-assistant', $plugin_url . '/assets/js/ai-assistant.js', array( 'wp-plugins', 'wp-element', 'wp-components', 'wp-data', 'wp-blocks', 'wp-block-editor', 'wp-editor', 'wp-i18n', 'wp-api-fetch', 'wp-compose', 'wp-notices', 'wp-dom-ready', 'wp-keyboard-shortcuts', 'bkbg-ai-external', ), filemtime( $js ), true ); wp_enqueue_script( 'bkbg-html-to-image', $plugin_url . '/assets/js/vendor/html-to-image-1.11.13.js', array(), '1.11.13', true ); 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 ); 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 ); if ( file_exists( $css ) ) { wp_enqueue_style( 'bkbg-ai-assistant', $plugin_url . '/assets/css/ai-assistant.css', array( 'wp-components' ), filemtime( $css ) ); } $settings = bkbg_ai_get_settings(); // wp_localize_script() casts everything to strings, which would turn // booleans and numbers into truthy strings — encode the config instead. $config = array( 'restBase' => esc_url_raw( rest_url( 'blockenberg/v1' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'configured' => '' !== bkbg_ai_get_api_key(), 'canManage' => current_user_can( 'manage_options' ), 'canUpload' => current_user_can( 'upload_files' ), 'settingsUrl' => admin_url( 'admin.php?page=blockenberg-ai' ), 'model' => $settings['model'], 'preferredModel' => bkbg_ai_get_preferred_model(), 'userId' => get_current_user_id(), 'conversations' => bkbg_chat_read( get_current_user_id(), bkbg_ai_editor_post_id() ), 'siteId' => get_current_blog_id(), 'imageSource' => $settings['image_source'], 'maxSteps' => (int) $settings['max_steps'], 'stream' => ! empty( $settings['stream'] ) && function_exists( 'curl_init' ), 'temperature' => (float) $settings['temperature'], 'siteContext' => $settings['site_context'], 'siteName' => get_bloginfo( 'name' ), 'cssMetaKey' => BKBG_AI_CSS_META, 'innerBlocks' => bkbg_ai_inner_blocks_map(), 'openSidebar' => ! empty( $settings['open_sidebar'] ), ); wp_add_inline_script( 'bkbg-ai-assistant', 'window.bkbgAI = ' . wp_json_encode( $config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) . ';', 'before' ); 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' ); wp_set_script_translations( 'bkbg-ai-assistant', 'blockenberg' ); wp_set_script_translations( 'bkbg-ai-external', 'blockenberg' ); } ); /** * Custom HTML has no save wrapper, so constrained layouts box it like a paragraph. * Mark the output full-width so AI sections (and other HTML blocks) span the page. */ add_filter( 'register_block_type_args', function ( $args, $block_type ) { if ( 'core/html' !== $block_type ) { return $args; } if ( ! isset( $args['supports'] ) || ! is_array( $args['supports'] ) ) { $args['supports'] = array(); } $args['supports']['align'] = array( 'wide', 'full' ); return $args; }, 10, 2 ); add_filter( 'render_block_core/html', function ( $content ) { $trimmed = ltrim( (string) $content ); if ( '' === $trimmed ) { return $content; } if ( preg_match( '/^<[a-zA-Z][^>]*\balignfull\b/', $trimmed ) ) { return $content; } return '
' . $content . '
'; } ); add_action( 'wp_enqueue_scripts', function () { $css = '.is-layout-constrained > .wp-block-html.alignfull,' . '.is-layout-constrained > section.alignfull,' . '.is-layout-constrained > header.alignfull,' . '.is-layout-constrained > footer.alignfull,' . '.is-layout-constrained > article.alignfull {' . 'max-width: none;' . '}' . '.wp-block-html.alignfull,' . '.is-layout-constrained > section.alignfull,' . '.is-layout-constrained > header.alignfull,' . '.is-layout-constrained > footer.alignfull,' . '.is-layout-constrained > article.alignfull {' . 'box-sizing: border-box;' . 'overflow-x: clip;' . '}' . '.wp-block-html.alignfull > :where(section, div, header, footer, article, main, aside),' . '.is-layout-constrained > section.alignfull,' . '.is-layout-constrained > header.alignfull,' . '.is-layout-constrained > footer.alignfull,' . '.is-layout-constrained > article.alignfull {' . 'box-sizing: border-box !important;' . 'width: 100% !important;' . 'max-width: 100% !important;' . 'margin-left: 0 !important;' . 'margin-right: 0 !important;' . 'left: auto !important;' . 'transform: none !important;' . '}' . '.wp-block-html.alignfull img,' . '.wp-block-html.alignfull video,' . '.wp-block-html.alignfull iframe,' . '.is-layout-constrained > section.alignfull img,' . '.is-layout-constrained > section.alignfull video,' . '.is-layout-constrained > section.alignfull iframe {' . 'max-width: 100%;' . 'height: auto;' . '}'; wp_register_style( 'bkbg-html-sections', false, array(), '1' ); wp_enqueue_style( 'bkbg-html-sections' ); wp_add_inline_style( 'bkbg-html-sections', $css ); } );