| 1 |
<?php |
| 2 |
/** |
| 3 |
* Helper functions for the AI plugin. |
| 4 |
* |
| 5 |
* @package WordPress\AI |
| 6 |
*/ |
| 7 |
|
| 8 |
declare( strict_types=1 ); |
| 9 |
|
| 10 |
namespace WordPress\AI; |
| 11 |
|
| 12 |
use Throwable; |
| 13 |
use WordPress\AI\Abilities\Utilities\Posts; |
| 14 |
use WordPress\AI\Experiments\Summarization\Summarization; |
| 15 |
use WordPress\AI\Logging\AI_Request_Log_Manager; |
| 16 |
use WordPress\AI\Logging\Logging_Integration; |
| 17 |
use WordPress\AI\Services\AI_Service; |
| 18 |
use WordPress\AI\Services\Guidelines; |
| 19 |
use WordPress\AiClient\AiClient; |
| 20 |
use WordPress\AiClient\Builders\EmbeddingBuilder; |
| 21 |
use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; |
| 22 |
|
| 23 |
/** |
| 24 |
* Purposely using return instead of exit here. |
| 25 |
* |
| 26 |
* This file is loaded via the composer files directive. |
| 27 |
* When tools like PHPCS and PHPStan run, they include |
| 28 |
* our composer autoloader and that will then load this file, |
| 29 |
* causing the script to exit and not function properly. |
| 30 |
*/ |
| 31 |
if ( ! defined( 'ABSPATH' ) ) { |
| 32 |
return; |
| 33 |
} |
| 34 |
|
| 35 |
/** |
| 36 |
* Normalizes the content by cleaning it and removing unwanted HTML tags. |
| 37 |
* |
| 38 |
* @since 0.1.0 |
| 39 |
* |
| 40 |
* @param string $content The content to normalize. |
| 41 |
* @return string The normalized content. |
| 42 |
*/ |
| 43 |
function normalize_content( string $content ): string { |
| 44 |
/** |
| 45 |
* Hook to filter content before cleaning it. |
| 46 |
* |
| 47 |
* @since 0.1.0 |
| 48 |
* |
| 49 |
* @param string $post_content The post content. |
| 50 |
* |
| 51 |
* @return string The filtered Post content. |
| 52 |
*/ |
| 53 |
$content = (string) apply_filters( 'wpai_pre_normalize_content', $content ); |
| 54 |
|
| 55 |
// Strip HTML entities. |
| 56 |
$content = preg_replace( '/&#?[a-z0-9]{2,8};/i', '', $content ) ?? $content; |
| 57 |
|
| 58 |
// Replace HTML linebreaks with newlines. |
| 59 |
$content = preg_replace( '#<br\s?/?>#', "\n\n", $content ) ?? $content; |
| 60 |
|
| 61 |
// Remove linebreaks but replace with spaces to avoid sentences running together. |
| 62 |
$content = str_replace( array( "\r", "\n" ), ' ', (string) $content ); |
| 63 |
|
| 64 |
// Strip all HTML tags. |
| 65 |
$content = wp_strip_all_tags( (string) $content ); |
| 66 |
|
| 67 |
// Remove unrendered shortcode tags. |
| 68 |
$content = preg_replace( '#\[.+\](.+)\[/.+\]#', '$1', $content ) ?? $content; |
| 69 |
|
| 70 |
/** |
| 71 |
* Filters the normalized content to allow for additional cleanup. |
| 72 |
* |
| 73 |
* @since 0.1.0 |
| 74 |
* |
| 75 |
* @param string $content The normalized content. |
| 76 |
* |
| 77 |
* @return string The filtered normalized content. |
| 78 |
*/ |
| 79 |
$content = (string) apply_filters( 'wpai_normalize_content', (string) $content ); |
| 80 |
|
| 81 |
return trim( $content ); |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* Counts characters excluding whitespace, with Unicode support. |
| 86 |
* |
| 87 |
* This approximately mirrors @wordpress/wordcount's |
| 88 |
* `characters_excluding_spaces` strategy used in the editor. |
| 89 |
* |
| 90 |
* @since 1.1.0 |
| 91 |
* |
| 92 |
* @param string $text The text to count characters in. |
| 93 |
* @return int The number of non-whitespace characters. |
| 94 |
*/ |
| 95 |
function count_characters_excluding_spaces( string $text ): int { |
| 96 |
if ( empty( $text ) ) { |
| 97 |
return 0; |
| 98 |
} |
| 99 |
|
| 100 |
// Strip all HTML tags including comments. |
| 101 |
$text = wp_strip_all_tags( $text ); |
| 102 |
|
| 103 |
// Normalize NBSP entities to whitespace. |
| 104 |
$text = preg_replace( '/ | /i', ' ', $text ) ?? $text; |
| 105 |
|
| 106 |
// Transpose HTML entities to countable characters. |
| 107 |
$text = preg_replace( '/&\S+?;/u', 'a', $text ) ?? $text; |
| 108 |
|
| 109 |
/* |
| 110 |
* Count non-whitespace code points using a class that mirrors JavaScript's |
| 111 |
* \s semantics, so full-width CJK spaces and similar separators match the |
| 112 |
* editor's @wordpress/wordcount result. |
| 113 |
*/ |
| 114 |
$whitespace = '\x{0009}\x{000A}\x{000B}\x{000C}\x{000D}\x{0020}\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}'; |
| 115 |
$count = preg_match_all( sprintf( '/[^%s]/u', $whitespace ), $text ); |
| 116 |
|
| 117 |
return is_int( $count ) ? $count : 0; |
| 118 |
} |
| 119 |
|
| 120 |
/** |
| 121 |
* Returns the context for the given post ID. |
| 122 |
* |
| 123 |
* Reads the post details directly rather than through the get-post-details |
| 124 |
* ability, so it works even when that ability is gated off. Because it does not |
| 125 |
* go through WP_Ability::execute(), the ability's permission callback is NOT |
| 126 |
* run. Callers are responsible for performing their own capability/permission |
| 127 |
* checks before exposing this data. |
| 128 |
* |
| 129 |
* @since 0.1.0 |
| 130 |
* |
| 131 |
* @param int $post_id The ID of the post to get the context for. |
| 132 |
* @return array<string, string> The context for the given post ID. |
| 133 |
*/ |
| 134 |
function get_post_context( int $post_id ): array { |
| 135 |
$context = array(); |
| 136 |
|
| 137 |
// Get the post details directly (not via the ability) so the context is |
| 138 |
// available even when the get-post-details ability is gated off. |
| 139 |
$details = Posts::get_post_details( $post_id ); |
| 140 |
|
| 141 |
if ( is_array( $details ) ) { |
| 142 |
$context = array_merge( $context, $details ); |
| 143 |
|
| 144 |
if ( isset( $context['content'] ) ) { |
| 145 |
// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound |
| 146 |
$context['content'] = normalize_content( (string) apply_filters( 'the_content', $context['content'] ) ); |
| 147 |
} |
| 148 |
|
| 149 |
if ( isset( $context['type'] ) ) { |
| 150 |
$context['content_type'] = $context['type']; |
| 151 |
unset( $context['type'] ); |
| 152 |
} |
| 153 |
|
| 154 |
// Remove any empty context values. |
| 155 |
$context = array_filter( $context ); |
| 156 |
} |
| 157 |
|
| 158 |
// Get the post terms directly (not via the ability) so the context is |
| 159 |
// available even when the get-post-terms ability is gated off. |
| 160 |
$terms = Posts::get_post_terms( $post_id ); |
| 161 |
|
| 162 |
if ( $terms && ! is_wp_error( $terms ) ) { |
| 163 |
$grouped_terms = array(); |
| 164 |
|
| 165 |
foreach ( $terms as $term ) { |
| 166 |
$taxonomy = $term['taxonomy'] ?? ''; |
| 167 |
$name = $term['name'] ?? ''; |
| 168 |
|
| 169 |
if ( '' === $taxonomy || '' === $name ) { |
| 170 |
continue; |
| 171 |
} |
| 172 |
|
| 173 |
$grouped_terms[ $taxonomy ][] = $name; |
| 174 |
} |
| 175 |
|
| 176 |
$context = array_merge( |
| 177 |
$context, |
| 178 |
array_map( |
| 179 |
static fn( array $term_names ): string => implode( ', ', $term_names ), |
| 180 |
$grouped_terms |
| 181 |
) |
| 182 |
); |
| 183 |
} |
| 184 |
|
| 185 |
return $context; |
| 186 |
} |
| 187 |
|
| 188 |
/** |
| 189 |
* Returns the preferred models for text generation. |
| 190 |
* |
| 191 |
* @since 0.2.1 |
| 192 |
* |
| 193 |
* @return array<int, array{string, string}> The preferred models for text generation. |
| 194 |
*/ |
| 195 |
function get_preferred_models_for_text_generation(): array { |
| 196 |
$preferred_models = array( |
| 197 |
array( |
| 198 |
'anthropic', |
| 199 |
'claude-sonnet-5', |
| 200 |
), |
| 201 |
array( |
| 202 |
'google', |
| 203 |
'gemini-3.6-flash', |
| 204 |
), |
| 205 |
array( |
| 206 |
'google', |
| 207 |
'gemini-3.5-flash-lite', |
| 208 |
), |
| 209 |
array( |
| 210 |
'openai', |
| 211 |
'gpt-5.6-luna', |
| 212 |
), |
| 213 |
array( |
| 214 |
'openai', |
| 215 |
'gpt-5.4-mini', |
| 216 |
), |
| 217 |
); |
| 218 |
|
| 219 |
/** |
| 220 |
* Filters the preferred models for text generation. |
| 221 |
* |
| 222 |
* @since 0.2.1 |
| 223 |
* |
| 224 |
* @param array<int, array{string, string}> $preferred_models The preferred models for text generation. |
| 225 |
* @return array<int, array{string, string}> The filtered preferred models. |
| 226 |
*/ |
| 227 |
return (array) apply_filters( 'wpai_preferred_text_models', $preferred_models ); |
| 228 |
} |
| 229 |
|
| 230 |
/** |
| 231 |
* Gets the AI Service instance. |
| 232 |
* |
| 233 |
* Call `wp_ai_client_prompt()` directly instead. The prompt builder it returns |
| 234 |
* exposes the full SDK API, so the only behavior this helper added was applying |
| 235 |
* `get_preferred_models_for_text_generation()` by default: |
| 236 |
* |
| 237 |
* ```php |
| 238 |
* $builder = wp_ai_client_prompt( 'Summarize this article...' ); |
| 239 |
* |
| 240 |
* $models = WordPress\AI\get_preferred_models_for_text_generation(); |
| 241 |
* if ( ! empty( $models ) ) { |
| 242 |
* $builder = $builder->using_model_preference( ...$models ); |
| 243 |
* } |
| 244 |
* ``` |
| 245 |
* |
| 246 |
* @since 0.2.1 |
| 247 |
* @deprecated 1.3.0 Use wp_ai_client_prompt() instead. |
| 248 |
* |
| 249 |
* @return \WordPress\AI\Services\AI_Service The AI Service instance. |
| 250 |
*/ |
| 251 |
function get_ai_service(): AI_Service { |
| 252 |
_deprecated_function( __FUNCTION__, '1.3.0', 'wp_ai_client_prompt()' ); |
| 253 |
|
| 254 |
return AI_Service::get_instance(); |
| 255 |
} |
| 256 |
|
| 257 |
/** |
| 258 |
* Returns the preferred image models. |
| 259 |
* |
| 260 |
* @since 0.2.0 |
| 261 |
* |
| 262 |
* @return array<int, array{string, string}> The preferred image models. |
| 263 |
*/ |
| 264 |
function get_preferred_image_models(): array { |
| 265 |
$preferred_models = array( |
| 266 |
array( |
| 267 |
'google', |
| 268 |
'gemini-3.1-flash-image-preview', |
| 269 |
), |
| 270 |
array( |
| 271 |
'google', |
| 272 |
'gemini-3-pro-image-preview', |
| 273 |
), |
| 274 |
array( |
| 275 |
'google', |
| 276 |
'gemini-2.5-flash-image', |
| 277 |
), |
| 278 |
array( |
| 279 |
'google', |
| 280 |
'imagen-4.0-generate-001', |
| 281 |
), |
| 282 |
array( |
| 283 |
'openai', |
| 284 |
'gpt-image-2', |
| 285 |
), |
| 286 |
array( |
| 287 |
'openai', |
| 288 |
'gpt-image-1.5', |
| 289 |
), |
| 290 |
); |
| 291 |
|
| 292 |
/** |
| 293 |
* Filters the preferred image models. |
| 294 |
* |
| 295 |
* @since 0.2.0 |
| 296 |
* |
| 297 |
* @param array<int, array{string, string}> $preferred_models The preferred image models. |
| 298 |
* @return array<int, array{string, string}> The filtered preferred image models. |
| 299 |
*/ |
| 300 |
return (array) apply_filters( 'wpai_preferred_image_models', $preferred_models ); |
| 301 |
} |
| 302 |
|
| 303 |
/** |
| 304 |
* Returns the preferred vision models. |
| 305 |
* |
| 306 |
* @since 0.3.0 |
| 307 |
* |
| 308 |
* @return array<int, array{string, string}> The preferred vision models. |
| 309 |
*/ |
| 310 |
function get_preferred_vision_models(): array { |
| 311 |
$preferred_models = array( |
| 312 |
array( |
| 313 |
'anthropic', |
| 314 |
'claude-sonnet-5', |
| 315 |
), |
| 316 |
array( |
| 317 |
'google', |
| 318 |
'gemini-3.6-flash', |
| 319 |
), |
| 320 |
array( |
| 321 |
'google', |
| 322 |
'gemini-3.5-flash-lite', |
| 323 |
), |
| 324 |
array( |
| 325 |
'openai', |
| 326 |
'gpt-5.6-luna', |
| 327 |
), |
| 328 |
array( |
| 329 |
'openai', |
| 330 |
'gpt-5.4-mini', |
| 331 |
), |
| 332 |
); |
| 333 |
|
| 334 |
/** |
| 335 |
* Filters the preferred vision models. |
| 336 |
* |
| 337 |
* @since 0.3.0 |
| 338 |
* |
| 339 |
* @param array<int, array{string, string}> $preferred_models The preferred vision models. |
| 340 |
* @return array<int, array{string, string}> The filtered preferred vision models. |
| 341 |
*/ |
| 342 |
return (array) apply_filters( 'wpai_preferred_vision_models', $preferred_models ); |
| 343 |
} |
| 344 |
|
| 345 |
/** |
| 346 |
* Returns the developer-mode provider/model config saved for a feature. |
| 347 |
* |
| 348 |
* @since 0.9.0 |
| 349 |
* |
| 350 |
* @param string $feature_id The feature ID (e.g. 'excerpt-generation'). |
| 351 |
* @return array{provider: string, model: string} The saved provider and model, or empty strings if unset. |
| 352 |
*/ |
| 353 |
function get_feature_developer_model_config( string $feature_id ): array { |
| 354 |
$option = get_option( "wpai_feature_{$feature_id}_field_developer", array() ); |
| 355 |
return array( |
| 356 |
'provider' => is_array( $option ) ? ( $option['provider'] ?? '' ) : '', |
| 357 |
'model' => is_array( $option ) ? ( $option['model'] ?? '' ) : '', |
| 358 |
); |
| 359 |
} |
| 360 |
|
| 361 |
/** |
| 362 |
* Retrieves guidelines, optionally filtered by category. |
| 363 |
* |
| 364 |
* @since 0.8.0 |
| 365 |
* |
| 366 |
* @param string|null $category Optional. Guideline category to retrieve. |
| 367 |
* @return array<string, string>|null Keyed array of guidelines, or null when unavailable. |
| 368 |
*/ |
| 369 |
function get_guidelines( ?string $category = null ): ?array { |
| 370 |
return Guidelines::get_instance()->get_guidelines( $category ); |
| 371 |
} |
| 372 |
|
| 373 |
/** |
| 374 |
* Formats guidelines as an XML-tagged string for prompt injection. |
| 375 |
* |
| 376 |
* @since 0.8.0 |
| 377 |
* |
| 378 |
* @param list<string> $categories Guideline category slugs to include. |
| 379 |
* @param string|null $block_name Optional block name for block-specific guidelines. |
| 380 |
* @return string Formatted guidelines XML string, or empty string. |
| 381 |
*/ |
| 382 |
function format_guidelines_for_prompt( array $categories, ?string $block_name = null ): string { |
| 383 |
return Guidelines::get_instance()->format_for_prompt( $categories, $block_name ); |
| 384 |
} |
| 385 |
|
| 386 |
/** |
| 387 |
* Determines if a connector is configured. |
| 388 |
* |
| 389 |
* @since 1.0.1 |
| 390 |
* |
| 391 |
* @param string $connector_id The connector ID. |
| 392 |
* @return bool True if the connector is configured, false otherwise. |
| 393 |
*/ |
| 394 |
function is_connector_configured( string $connector_id ): bool { |
| 395 |
$registry = AiClient::defaultRegistry(); |
| 396 |
return $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); |
| 397 |
} |
| 398 |
|
| 399 |
/** |
| 400 |
* Determines if a connector has authentication in place. |
| 401 |
* |
| 402 |
* This checks for API-key credentials by source only (environment variable, |
| 403 |
* PHP constant, or stored option) and does not make external API requests. |
| 404 |
* |
| 405 |
* @since 1.0.1 |
| 406 |
* |
| 407 |
* @param string $connector_id The connector ID. |
| 408 |
* @return bool True if connector authentication is present, false otherwise. |
| 409 |
*/ |
| 410 |
function has_connector_authentication( string $connector_id ): bool { |
| 411 |
if ( ! wp_is_connector_registered( $connector_id ) ) { |
| 412 |
return false; |
| 413 |
} |
| 414 |
|
| 415 |
$connector = wp_get_connector( $connector_id ); |
| 416 |
if ( ! is_array( $connector ) ) { |
| 417 |
return false; |
| 418 |
} |
| 419 |
|
| 420 |
$auth = $connector['authentication'] ?? null; |
| 421 |
if ( ! is_array( $auth ) || ( $auth['method'] ?? '' ) !== 'api_key' ) { |
| 422 |
return false; |
| 423 |
} |
| 424 |
|
| 425 |
$setting_name = $auth['setting_name'] ?? ''; |
| 426 |
if ( ! is_string( $setting_name ) || '' === $setting_name ) { |
| 427 |
return false; |
| 428 |
} |
| 429 |
|
| 430 |
return 'none' !== get_connector_api_key_source( |
| 431 |
$setting_name, |
| 432 |
$auth['env_var_name'] ?? '', |
| 433 |
$auth['constant_name'] ?? '' |
| 434 |
); |
| 435 |
} |
| 436 |
|
| 437 |
/** |
| 438 |
* Determines the source of a connector API key. |
| 439 |
* |
| 440 |
* Checks in order: environment variable, PHP constant, database option. |
| 441 |
* |
| 442 |
* @since 1.0.1 |
| 443 |
* |
| 444 |
* @param string $setting_name The option name for the API key. |
| 445 |
* @param string $env_var_name Optional environment variable name. |
| 446 |
* @param string $constant_name Optional PHP constant name. |
| 447 |
* @return string The key source: 'env', 'constant', 'database', or 'none'. |
| 448 |
*/ |
| 449 |
function get_connector_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string { |
| 450 |
if ( '' !== $env_var_name ) { |
| 451 |
$env_value = getenv( $env_var_name ); |
| 452 |
if ( false !== $env_value && '' !== $env_value ) { |
| 453 |
return 'env'; |
| 454 |
} |
| 455 |
} |
| 456 |
|
| 457 |
if ( '' !== $constant_name && defined( $constant_name ) ) { |
| 458 |
$const_value = constant( $constant_name ); |
| 459 |
if ( is_string( $const_value ) && '' !== $const_value ) { |
| 460 |
return 'constant'; |
| 461 |
} |
| 462 |
} |
| 463 |
|
| 464 |
$db_value = get_option( $setting_name, '' ); |
| 465 |
if ( '' !== $db_value ) { |
| 466 |
return 'database'; |
| 467 |
} |
| 468 |
|
| 469 |
return 'none'; |
| 470 |
} |
| 471 |
|
| 472 |
/** |
| 473 |
* Checks if we have AI credentials set. |
| 474 |
* |
| 475 |
* @since 0.1.0 |
| 476 |
* |
| 477 |
* @return bool True if we have AI credentials, false otherwise. |
| 478 |
*/ |
| 479 |
function has_ai_credentials(): bool { |
| 480 |
$connectors = get_ai_connectors(); |
| 481 |
$has_credentials = false; |
| 482 |
|
| 483 |
foreach ( $connectors as $connector_id => $connector_data ) { |
| 484 |
$auth = $connector_data['authentication']; |
| 485 |
if ( 'api_key' !== $auth['method'] ) { |
| 486 |
continue; |
| 487 |
} |
| 488 |
|
| 489 |
if ( ! has_connector_authentication( $connector_id ) ) { |
| 490 |
continue; |
| 491 |
} |
| 492 |
|
| 493 |
$has_credentials = true; |
| 494 |
break; |
| 495 |
} |
| 496 |
|
| 497 |
/** |
| 498 |
* Filters whether AI credentials are available. |
| 499 |
* |
| 500 |
* Allows third-party plugins to declare credential availability for |
| 501 |
* connectors that do not rely on API key settings. |
| 502 |
* |
| 503 |
* @since 0.7.0 |
| 504 |
* |
| 505 |
* @param bool $has_credentials Whether AI credentials are available. |
| 506 |
* @param array $connectors The registered connectors. |
| 507 |
*/ |
| 508 |
return (bool) apply_filters( 'wpai_has_ai_credentials', $has_credentials, $connectors ); |
| 509 |
} |
| 510 |
|
| 511 |
/** |
| 512 |
* Checks whether any configured connector exposes an image-generation-capable model. |
| 513 |
* |
| 514 |
* @since 1.0.2 |
| 515 |
* |
| 516 |
* @param bool $reset_cache Whether to bypass the static cache and recompute. Default false. |
| 517 |
* @return bool True if at least one connector supports image generation. |
| 518 |
*/ |
| 519 |
function has_image_generation_support( bool $reset_cache = false ): bool { |
| 520 |
static $result = null; |
| 521 |
|
| 522 |
if ( ! $reset_cache && null !== $result ) { |
| 523 |
return $result; |
| 524 |
} |
| 525 |
|
| 526 |
$connectors = array(); |
| 527 |
$has_support = false; |
| 528 |
|
| 529 |
if ( class_exists( AiClient::class ) ) { |
| 530 |
$registry = AiClient::defaultRegistry(); |
| 531 |
$connectors = get_ai_connectors(); |
| 532 |
|
| 533 |
foreach ( array_keys( $connectors ) as $connector_id ) { |
| 534 |
if ( ! has_connector_authentication( $connector_id ) ) { |
| 535 |
continue; |
| 536 |
} |
| 537 |
|
| 538 |
try { |
| 539 |
$provider_class = $registry->getProviderClassName( $connector_id ); |
| 540 |
|
| 541 |
/** @var \WordPress\AiClient\Providers\Contracts\ProviderInterface $provider_class */ |
| 542 |
$models = $provider_class::modelMetadataDirectory()->listModelMetadata(); |
| 543 |
|
| 544 |
foreach ( $models as $model ) { |
| 545 |
foreach ( $model->getSupportedCapabilities() as $capability ) { |
| 546 |
if ( CapabilityEnum::IMAGE_GENERATION === $capability->value ) { |
| 547 |
$has_support = true; |
| 548 |
break 3; |
| 549 |
} |
| 550 |
} |
| 551 |
} |
| 552 |
} catch ( Throwable $e ) { |
| 553 |
continue; |
| 554 |
} |
| 555 |
} |
| 556 |
} |
| 557 |
|
| 558 |
/** |
| 559 |
* Filters whether image generation is supported. |
| 560 |
* |
| 561 |
* Allows third-party plugins to declare image generation support for |
| 562 |
* connectors that do not rely on API key settings (e.g. OAuth), without |
| 563 |
* triggering a live API request. |
| 564 |
* |
| 565 |
* @since 1.1.0 |
| 566 |
* |
| 567 |
* @param bool $has_support Whether image generation is supported. |
| 568 |
* @param array $connectors The registered connectors. |
| 569 |
*/ |
| 570 |
$result = (bool) apply_filters( 'wpai_has_image_generation_support', $has_support, $connectors ); |
| 571 |
|
| 572 |
return $result; |
| 573 |
} |
| 574 |
|
| 575 |
/** |
| 576 |
* Returns provider availability data for script localization. |
| 577 |
* |
| 578 |
* @since 1.0.0 |
| 579 |
* |
| 580 |
* @return array{hasProvider: bool, connectorsUrl: string} Provider availability data. |
| 581 |
*/ |
| 582 |
function get_provider_availability_data(): array { |
| 583 |
return array( |
| 584 |
'hasProvider' => has_ai_credentials(), |
| 585 |
'connectorsUrl' => admin_url( 'options-connectors.php' ), |
| 586 |
); |
| 587 |
} |
| 588 |
|
| 589 |
/** |
| 590 |
* Checks if we have valid AI credentials. |
| 591 |
* |
| 592 |
* @since 0.1.0 |
| 593 |
* |
| 594 |
* @return bool True if we have valid AI credentials, false otherwise. |
| 595 |
*/ |
| 596 |
function has_valid_ai_credentials(): bool { |
| 597 |
// If we have no AI credentials, return false. |
| 598 |
if ( ! has_ai_credentials() ) { |
| 599 |
return false; |
| 600 |
} |
| 601 |
|
| 602 |
/** |
| 603 |
* Filters whether valid AI credentials are available. |
| 604 |
* |
| 605 |
* Allows overriding the credentials check, useful for testing. |
| 606 |
* |
| 607 |
* @since 0.1.0 |
| 608 |
* |
| 609 |
* @param bool|null $has_valid_credentials Whether valid credentials are available. Return null to use default check. |
| 610 |
* @return bool|null True if valid credentials are available, false otherwise, or null to use default check. |
| 611 |
*/ |
| 612 |
$valid = apply_filters( 'wpai_pre_has_valid_credentials_check', null ); |
| 613 |
if ( null !== $valid ) { |
| 614 |
return (bool) $valid; |
| 615 |
} |
| 616 |
|
| 617 |
// See if we have credentials that give us access to generate text. |
| 618 |
try { |
| 619 |
return wp_ai_client_prompt( 'Test' )->is_supported_for_text_generation(); |
| 620 |
} catch ( Throwable $t ) { |
| 621 |
return false; |
| 622 |
} |
| 623 |
} |
| 624 |
|
| 625 |
/** |
| 626 |
* Returns the AI connectors. |
| 627 |
* |
| 628 |
* @since 0.9.0 |
| 629 |
* |
| 630 |
* @param bool $active_only Whether to only return active connectors. |
| 631 |
* @return array<string, array<string, mixed>> The AI connectors. |
| 632 |
*/ |
| 633 |
function get_ai_connectors( bool $active_only = true ): array { |
| 634 |
$connectors = array(); |
| 635 |
|
| 636 |
foreach ( (array) wp_get_connectors() as $connector_id => $data ) { |
| 637 |
if ( ! is_string( $connector_id ) || ! is_array( $data ) ) { |
| 638 |
continue; |
| 639 |
} |
| 640 |
|
| 641 |
if ( ( $data['type'] ?? '' ) !== 'ai_provider' ) { |
| 642 |
continue; |
| 643 |
} |
| 644 |
|
| 645 |
if ( $active_only && ! is_connector_plugin_active( $data ) ) { |
| 646 |
continue; |
| 647 |
} |
| 648 |
|
| 649 |
$connectors[ $connector_id ] = $data; |
| 650 |
} |
| 651 |
|
| 652 |
return $connectors; |
| 653 |
} |
| 654 |
|
| 655 |
/** |
| 656 |
* Checks whether the connector's related plugin is currently active. |
| 657 |
* |
| 658 |
* If plugin metadata is not provided for a connector, it is treated as active. |
| 659 |
* |
| 660 |
* @since 0.9.0 |
| 661 |
* |
| 662 |
* @param array<string, mixed> $connector_data Connector metadata. |
| 663 |
* @return bool True if the connector plugin is active or unknown, false if known inactive. |
| 664 |
*/ |
| 665 |
function is_connector_plugin_active( array $connector_data ): bool { |
| 666 |
if ( empty( $connector_data['plugin'] ) || ! is_array( $connector_data['plugin'] ) ) { |
| 667 |
return true; |
| 668 |
} |
| 669 |
|
| 670 |
$plugin_file = ''; |
| 671 |
|
| 672 |
if ( ! empty( $connector_data['plugin']['file'] ) && is_string( $connector_data['plugin']['file'] ) ) { |
| 673 |
$plugin_file = $connector_data['plugin']['file']; |
| 674 |
} elseif ( ! empty( $connector_data['plugin']['plugin_file'] ) && is_string( $connector_data['plugin']['plugin_file'] ) ) { |
| 675 |
$plugin_file = $connector_data['plugin']['plugin_file']; |
| 676 |
} elseif ( ! empty( $connector_data['plugin']['pluginFile'] ) && is_string( $connector_data['plugin']['pluginFile'] ) ) { |
| 677 |
$plugin_file = $connector_data['plugin']['pluginFile']; |
| 678 |
} |
| 679 |
|
| 680 |
if ( '' === $plugin_file ) { |
| 681 |
return true; |
| 682 |
} |
| 683 |
|
| 684 |
if ( ! function_exists( 'is_plugin_active' ) ) { |
| 685 |
require_once ABSPATH . 'wp-admin/includes/plugin.php'; |
| 686 |
} |
| 687 |
|
| 688 |
if ( is_plugin_active( $plugin_file ) ) { |
| 689 |
return true; |
| 690 |
} |
| 691 |
|
| 692 |
return is_multisite() && function_exists( 'is_plugin_active_for_network' ) && is_plugin_active_for_network( $plugin_file ); |
| 693 |
} |
| 694 |
|
| 695 |
/** |
| 696 |
* Returns the minimum content length in characters required for a given feature. |
| 697 |
* |
| 698 |
* @since 1.1.0 |
| 699 |
* |
| 700 |
* @param string $feature_id The feature identifier (e.g. 'content-resizing', 'content-classification', 'summarization'). |
| 701 |
* @param int $content_length The default minimum content length in characters for the feature. |
| 702 |
* @return int The minimum content length in characters. |
| 703 |
*/ |
| 704 |
function get_min_content_length( string $feature_id, int $content_length = 250 ): int { |
| 705 |
/** |
| 706 |
* Filters the minimum content length required for a feature. |
| 707 |
* |
| 708 |
* @since 1.1.0 |
| 709 |
* |
| 710 |
* @param int $content_length The minimum content length in characters for the feature. |
| 711 |
* @param string $feature_id The feature identifier. |
| 712 |
*/ |
| 713 |
return (int) apply_filters( 'wpai_min_content_length', $content_length, $feature_id ); |
| 714 |
} |
| 715 |
|
| 716 |
/** |
| 717 |
* Gets the default request timeout used by a feature. |
| 718 |
* |
| 719 |
* @since 1.2.0 |
| 720 |
* |
| 721 |
* @param string $feature_id The ID of the feature. |
| 722 |
* @param int $default_timeout The default timeout in seconds. |
| 723 |
* @return int The request timeout. |
| 724 |
*/ |
| 725 |
function get_default_request_timeout( string $feature_id, int $default_timeout = 30 ): int { |
| 726 |
/** |
| 727 |
* Filters the default request timeout for a feature. |
| 728 |
* |
| 729 |
* @since 1.2.0 |
| 730 |
* |
| 731 |
* @param int $default_timeout The default timeout in seconds. |
| 732 |
* @param string $feature_id The ID of the feature. |
| 733 |
*/ |
| 734 |
return (int) apply_filters( 'wpai_default_request_timeout', $default_timeout, $feature_id ); |
| 735 |
} |
| 736 |
|
| 737 |
/** |
| 738 |
* Returns the maximum number of items a single bulk action may process. |
| 739 |
* |
| 740 |
* @since x.x.x |
| 741 |
* |
| 742 |
* @param string $feature_id The feature identifier (e.g. 'summarization'). |
| 743 |
* @return int The maximum number of items to process, always at least 1. |
| 744 |
*/ |
| 745 |
function get_bulk_action_max_items( string $feature_id ): int { |
| 746 |
/** |
| 747 |
* Filters the maximum number of items a single bulk action may process. |
| 748 |
* |
| 749 |
* @since x.x.x |
| 750 |
* |
| 751 |
* @param int $max_items The maximum number of items per bulk run. |
| 752 |
* @param string $feature_id The ID of the feature. |
| 753 |
*/ |
| 754 |
$max_items = (int) apply_filters( 'wpai_bulk_action_max_items', 100, $feature_id ); |
| 755 |
|
| 756 |
return max( 1, $max_items ); |
| 757 |
} |
| 758 |
|
| 759 |
/** |
| 760 |
* Determines whether a post type supports bulk AI actions for a given feature. |
| 761 |
* |
| 762 |
* @since 1.2.0 |
| 763 |
* |
| 764 |
* @param string $post_type The post type slug to check. |
| 765 |
* @param string $feature_id The feature identifier (e.g. 'summarization'). |
| 766 |
* @return bool True if the post type supports bulk AI actions for the feature. |
| 767 |
*/ |
| 768 |
function post_type_supports_bulk_action( string $post_type, string $feature_id ): bool { |
| 769 |
$post_type_obj = get_post_type_object( $post_type ); |
| 770 |
|
| 771 |
// Check if the post type is registered and supports REST API and UI. |
| 772 |
$base_supported = $post_type_obj |
| 773 |
&& ! empty( $post_type_obj->show_in_rest ) |
| 774 |
&& ! empty( $post_type_obj->show_ui ); |
| 775 |
|
| 776 |
switch ( $feature_id ) { |
| 777 |
case Summarization::get_id(): |
| 778 |
return $base_supported && 'attachment' !== $post_type; |
| 779 |
default: |
| 780 |
return $base_supported; |
| 781 |
} |
| 782 |
} |
| 783 |
|
| 784 |
/** |
| 785 |
* Records a request in the request log. |
| 786 |
* |
| 787 |
* @since 1.3.0 |
| 788 |
* |
| 789 |
* @param array{ |
| 790 |
* type: string, |
| 791 |
* operation: string, |
| 792 |
* provider?: string, |
| 793 |
* model?: string, |
| 794 |
* duration_ms?: int, |
| 795 |
* tokens_input?: int, |
| 796 |
* tokens_output?: int, |
| 797 |
* status: string, |
| 798 |
* error_message?: string, |
| 799 |
* user_id?: int, |
| 800 |
* context?: array<string, mixed> |
| 801 |
* } $data Log data. The `type` must be one of the values returned by |
| 802 |
* {@see AI_Request_Log_Manager::get_types()}. |
| 803 |
* @return string|false The log identifier on success, false when logging is inactive or the write failed. |
| 804 |
*/ |
| 805 |
function log_ai_request( array $data ) { |
| 806 |
$log_manager = Logging_Integration::get_log_manager(); |
| 807 |
|
| 808 |
if ( ! $log_manager instanceof AI_Request_Log_Manager ) { |
| 809 |
return false; |
| 810 |
} |
| 811 |
|
| 812 |
return $log_manager->log( $data ); |
| 813 |
} |
| 814 |
|
| 815 |
/** |
| 816 |
* Determines whether embedding generation is available in this environment. |
| 817 |
* |
| 818 |
* @since 1.3.0 |
| 819 |
* |
| 820 |
* @return bool True if embeddings can be generated, false otherwise. |
| 821 |
*/ |
| 822 |
function supports_embedding_generation(): bool { |
| 823 |
return class_exists( AiClient::class ) && class_exists( EmbeddingBuilder::class ); |
| 824 |
} |
| 825 |
|
| 826 |
/** |
| 827 |
* Generates embeddings for one or more text inputs. |
| 828 |
* |
| 829 |
* @since 1.3.0 |
| 830 |
* |
| 831 |
* @param string|list<string> $input The text input, or a list of inputs for batch embedding. |
| 832 |
* @param array<string, mixed> $args { |
| 833 |
* Optional. Generation options. |
| 834 |
* |
| 835 |
* @type string $provider Connector/provider ID to use. |
| 836 |
* @type list<string> $model_preference Ordered model preferences. |
| 837 |
* @type int $dimensions Requested embedding vector dimensions. |
| 838 |
* } |
| 839 |
* @return \WordPress\AiClient\Results\DTO\EmbeddingResult|\WP_Error The result, or WP_Error on failure. |
| 840 |
*/ |
| 841 |
function generate_embeddings( $input, array $args = array() ) { |
| 842 |
if ( ! supports_embedding_generation() ) { |
| 843 |
return new \WP_Error( |
| 844 |
'ai_embeddings_unsupported', |
| 845 |
__( 'Embedding generation is not available in this environment.', 'ai' ) |
| 846 |
); |
| 847 |
} |
| 848 |
|
| 849 |
try { |
| 850 |
$builder = new EmbeddingBuilder( AiClient::defaultRegistry(), $input ); |
| 851 |
|
| 852 |
if ( isset( $args['provider'] ) && is_string( $args['provider'] ) && '' !== $args['provider'] ) { |
| 853 |
$builder->usingProvider( $args['provider'] ); |
| 854 |
} |
| 855 |
|
| 856 |
if ( ! empty( $args['model_preference'] ) && is_array( $args['model_preference'] ) ) { |
| 857 |
$builder->usingModelPreference( ...array_values( $args['model_preference'] ) ); |
| 858 |
} |
| 859 |
|
| 860 |
if ( isset( $args['dimensions'] ) ) { |
| 861 |
$builder->usingDimensions( (int) $args['dimensions'] ); |
| 862 |
} |
| 863 |
|
| 864 |
return $builder->generateEmbeddingResult(); |
| 865 |
} catch ( Throwable $e ) { |
| 866 |
return new \WP_Error( 'ai_embeddings_failed', $e->getMessage() ); |
| 867 |
} |
| 868 |
} |
| 869 |
|