diff --git a/docs/experiments/image-generation.md b/docs/experiments/image-generation.md index fa53a712..46e79ca5 100644 --- a/docs/experiments/image-generation.md +++ b/docs/experiments/image-generation.md @@ -2,7 +2,7 @@ ## Summary -The Image Generation experiment adds AI-powered featured image generation to the WordPress post editor. It provides a "Generate featured image" button in the featured image panel that uses AI to create images based on post content. The experiment registers two WordPress Abilities (`ai/image-generation` and `ai/image-import`) that can be used both through the admin UI and directly via REST API requests. +The Image Generation experiment adds AI-powered featured image generation to the WordPress post editor. It provides a "Generate featured image" button in the featured image panel that uses AI to create images based on post content. The experiment registers three WordPress Abilities (`ai/image-generation`, `ai/image-import`, `ai/image-prompt-generation`) that can be used both through the admin UI and directly via REST API requests. ## Overview @@ -13,9 +13,11 @@ When enabled, the Image Generation experiment adds a "Generate featured image" b **Key Features:** - One-click featured image generation from post content +- Step-by-step progress messages during generation (e.g. "Generating image prompt", "Generating image", "Generating alt text", "Importing image") - Automatically imports generated images into the media library - Sets generated images as featured images - Uses AI to create an image generation prompt from post context +- Optional AI-generated alt text when the Alt Text Generation experiment is enabled - Works with any post type that supports featured images - Visual indicator for AI-generated images @@ -44,27 +46,26 @@ All three abilities can be called directly via REST API, making them useful for 1. **PHP Side:** - `enqueue_assets()` loads `experiments/image-generation` (`src/experiments/image-generation/index.ts`) and localizes `window.aiImageGenerationData` with: - `enabled`: Whether the experiment is enabled - - `generateImagePath`: REST API path to image generation ability (`wp-abilities/v1/abilities/ai/image-generation/run`) - - `importPath`: REST API path to image import ability (`wp-abilities/v1/abilities/ai/image-import/run`) - - `getContextPath`: REST API path to get post details (`wp-abilities/v1/abilities/ai/get-post-details/run`) - - `generatePromptPath`: REST API path to image prompt generation ability (`wp-abilities/v1/abilities/ai/image-prompt-generation/run`) + - `altTextEnabled`: Whether the alt text generation experiment is enabled 2. **React Side:** - The React entry point (`featured-image.tsx`) hooks into the featured image panel using the `editor.PostFeaturedImage` filter - - `GenerateFeaturedImage` component renders a button that: + - `GenerateFeaturedImage` component renders a button and progress UI that: - Gets current post ID and content from the editor store - - Calls `generateImage()` function which: - - Gets post context (title, type) via `getContext()` + - Tracks `progressMessage` state and passes an `onProgress` callback to `generateImage()` and `uploadImage()` + - Calls `generateImage( postId, content, { onProgress } )`, which: + - Gets post context (title, type) via `getContext()` (uses `ai/get-post-details` ability) - Formats context using `formatContext()` - - Calls `generatePrompt()` to create an image generation prompt from content and context - - Calls the image generation ability with the generated prompt - - Returns base64-encoded image data - - Calls `uploadImage()` function which: - - Calls the image import ability with the base64 data - - Sets `ai_generated` meta to mark the image + - Invokes `onProgress( 'Generating image prompt' )`, then calls `generatePrompt()` to create an image generation prompt from content and context + - Invokes `onProgress( 'Generating image' )`, then calls the `ai/image-generation` ability with the generated prompt + - Returns generated image data (base64 data, prompt, provider/model metadata) + - Calls `uploadImage( imageData, { onProgress } )`, which: + - If the Alt Text Generation experiment is enabled (`aiImageGenerationData.altTextEnabled`): invokes `onProgress( 'Generating alt text' )`, then calls `generateAltText()` and uses the result as `alt_text`; otherwise uses the prompt as fallback alt text + - Invokes `onProgress( 'Importing image' )`, then calls the `ai/image-import` ability with base64 data, metadata, and `ai_generated` meta - Returns attachment data (id, url, title) - Updates the editor store to set the imported image as featured image - - Handles loading states and error notifications + - Shows a loading state on the button and a progress message (with spinner) under the button while generating; clears both on success or error + - Handles error notifications via the notices store - `AILabel` component displays a label for AI-generated images by checking the `ai_generated` meta 3. **Ability Execution Flow:** @@ -657,7 +658,9 @@ You can customize what metadata is saved when importing images by modifying the src/experiments/image-generation/functions/upload-image.ts ``` -Or by filtering the input before calling the import ability via REST API. +`uploadImage( imageData, options? )` accepts generated image data and an optional `options` object with `onProgress?: ( message: string ) => void` for progress callbacks. When the Alt Text Generation experiment is enabled, it generates alt text via `generateAltText()` before importing; otherwise it uses the image prompt as alt text. + +You can also filter the input before calling the import ability via REST API. ### Customizing Post Context @@ -677,8 +680,9 @@ src/experiments/image-generation/functions/format-context.ts You can extend the React components to add custom UI elements: -1. **Modify the generate button component:** +1. **Modify the generate button and progress UI:** - Edit `src/experiments/image-generation/components/GenerateFeaturedImage.tsx` + - The component renders a button and, while generating, a progress container (`.ai-featured-image__progress`) that displays the current step and a spinner; progress is driven by the `onProgress` callbacks passed to `generateImage()` and `uploadImage()` 2. **Customize the AI label:** - Edit `src/experiments/image-generation/components/AILabel.tsx` @@ -722,6 +726,7 @@ add_filter( 'wp_generate_attachment_metadata', function( $metadata, $attachment_ - Create or edit a post with content - Scroll to the featured image panel - Click the "Generate featured image" button + - Verify progress messages appear in order: "Generating image prompt", "Generating image", then "Generating alt text" (if Alt Text experiment is enabled), then "Importing image" - Verify the image is generated, imported, and set as featured image - Verify the "AI Generated Featured Image" label appears - Click "Generate new featured image" to test regeneration @@ -763,7 +768,7 @@ npm run test:php ### Performance - Image generation is an AI operation and may take 30-90 seconds (timeout is set to 90 seconds) -- The UI shows a loading state while generation is in progress +- The UI shows a loading state on the button and step-by-step progress messages below it ("Generating image prompt" → "Generating image" → "Generating alt text" (if enabled) → "Importing image") so users know which step is running - Base64 image data can be large; ensure adequate memory and request timeout settings - Consider implementing caching for frequently accessed images if generating images in bulk @@ -819,16 +824,3 @@ npm run test:php - Temporary files are properly cleaned up after import - User permissions are checked before allowing image generation or import - All input is sanitized using WordPress sanitization functions - -## Related Files - -- **Experiment:** `includes/Experiments/Image_Generation/Image_Generation.php` -- **Generate Image Prompt Ability:** `includes/Abilities/Image/Generate_Image_Prompt.php` -- **Generate Image Prompt System Instruction:** `includes/Abilities/Image/image-prompt-system-instruction.php` -- **Generate Image Ability:** `includes/Abilities/Image/Generate_Image.php` -- **Import Image Ability:** `includes/Abilities/Image/Import_Base64_Image.php` -- **React Entry:** `src/experiments/image-generation/featured-image.tsx` -- **React Components:** `src/experiments/image-generation/components/` -- **React Functions:** `src/experiments/image-generation/functions/` -- **Tests:** `tests/Integration/Includes/Abilities/Image_GenerationTest.php` -- **Tests:** `tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php` diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index 2f640fec..b257df1a 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -9,10 +9,13 @@ namespace WordPress\AI\Abilities\Image; +use Throwable; use WP_Error; use WordPress\AI\Abstracts\Abstract_Ability; use WordPress\AI_Client\AI_Client; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use function WordPress\AI\get_preferred_image_models; @@ -49,8 +52,51 @@ protected function input_schema(): array { */ protected function output_schema(): array { return array( - 'type' => 'string', - 'description' => esc_html__( 'The base64 encoded image data.', 'ai' ), + 'type' => 'object', + 'properties' => array( + 'image' => array( + 'type' => 'object', + 'description' => esc_html__( 'Generated image data.', 'ai' ), + 'properties' => array( + 'data' => array( + 'type' => 'string', + 'description' => esc_html__( 'The base64 encoded image data.', 'ai' ), + ), + 'provider_metadata' => array( + 'type' => 'object', + 'description' => esc_html__( 'Information about the provider that generated the image.', 'ai' ), + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'description' => esc_html__( 'The provider ID.', 'ai' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => esc_html__( 'The provider name.', 'ai' ), + ), + 'type' => array( + 'type' => 'string', + 'description' => esc_html__( 'The provider type.', 'ai' ), + ), + ), + ), + 'model_metadata' => array( + 'type' => 'object', + 'description' => esc_html__( 'Information about the model that generated the image.', 'ai' ), + 'properties' => array( + 'id' => array( + 'type' => 'string', + 'description' => esc_html__( 'The model ID.', 'ai' ), + ), + 'name' => array( + 'type' => 'string', + 'description' => esc_html__( 'The model name.', 'ai' ), + ), + ), + ), + ), + ), + ), ); } @@ -77,7 +123,9 @@ protected function execute_callback( $input ) { } // Return the image data in the format the Ability expects. - return sanitize_text_field( trim( $result ) ); + return array( + 'image' => $result, + ); } /** @@ -114,27 +162,53 @@ protected function meta(): array { * @since 0.2.0 * * @param string $prompt The prompt to generate an image from. - * @return string|\WP_Error The generated image data, or a WP_Error if there was an error. + * @return array{data: string, provider_metadata: array, model_metadata: array}|\WP_Error The generated image data, provider metadata, and model metadata, or a WP_Error if there was an error. */ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.NamingConventions.ConstructorName.OldStyle // Generate the image using the AI client. - $file = AI_Client::prompt_with_wp_error( $prompt ) + $result = AI_Client::prompt_with_wp_error( $prompt ) ->as_output_file_type( FileTypeEnum::inline() ) ->using_model_preference( ...get_preferred_image_models() ) - ->generate_image(); + ->generate_image_result(); // If we have an error, return it. - if ( is_wp_error( $file ) ) { - return $file; + if ( is_wp_error( $result ) ) { + return $result; } - // Return the base64 encoded image data. - $data = $file->getBase64Data(); + $data = array( + 'data' => '', + 'provider_metadata' => array(), + 'model_metadata' => array(), + ); - if ( empty( $data ) ) { + try { + // Get the File from the result. + $file = $result->toImageFile(); + + // Return the base64 encoded image data. + $data['data'] = sanitize_text_field( trim( $file->getBase64Data() ?? '' ) ); + + if ( empty( $data['data'] ) ) { + return new WP_Error( + 'no_image_data', + esc_html__( 'No image data was generated.', 'ai' ) + ); + } + + // Get details about the provider and model that generated the image. + $data['provider_metadata'] = $result->getProviderMetadata()->toArray(); + $data['model_metadata'] = $result->getModelMetadata()->toArray(); + + // Remove data we don't care about. + unset( $data['provider_metadata'][ ProviderMetadata::KEY_CREDENTIALS_URL ] ); + unset( $data['model_metadata'][ ModelMetadata::KEY_SUPPORTED_OPTIONS ] ); + unset( $data['model_metadata'][ ModelMetadata::KEY_SUPPORTED_CAPABILITIES ] ); + } catch ( Throwable $t ) { return new WP_Error( 'no_image_data', - esc_html__( 'No image data was generated.', 'ai' ) + esc_html__( 'No image data was generated.', 'ai' ), + $t ); } diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php new file mode 100644 index 00000000..f4e0b90e --- /dev/null +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -0,0 +1,252 @@ + 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The content to use as inspiration for the generated image.', 'ai' ), + ), + 'context' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Any additional context to help generate the prompt. This can either be a string of additional context or can be a post ID that will then be used to get context from that post (if it exists).', 'ai' ), + ), + 'style' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Any additional style instructions to apply to the generated image.', 'ai' ), + ), + ), + 'required' => array( 'content' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'string', + 'description' => esc_html__( 'The image generation prompt.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function execute_callback( $input ) { + // Default arguments. + $args = wp_parse_args( + $input, + array( + 'content' => '', + 'context' => null, + 'style' => null, + ), + ); + + // If a post ID is provided, ensure the post exists before using it. + if ( is_numeric( $args['context'] ) ) { + $post = get_post( (int) $args['context'] ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $args['context'] ) ) + ); + } + + // Get the post context. + $context = get_post_context( $post->ID ); + $content = $context['content'] ?? ''; + unset( $context['content'] ); + + // Default to the passed in content if it exists. + if ( $args['content'] ) { + $content = normalize_content( $args['content'] ); + } + } else { + $content = normalize_content( $args['content'] ?? '' ); + $context = $args['context'] ?? ''; + } + + // If we have no content, return an error. + if ( empty( $content ) ) { + return new WP_Error( + 'content_not_provided', + esc_html__( 'Content is required to generate a prompt.', 'ai' ) + ); + } + + // Generate the prompt. + $result = $this->generate_prompt( $content, $context, $args['style'] ?? '' ); + + // If we have an error, return it. + if ( is_wp_error( $result ) ) { + return $result; + } + + // If we have no results, return an error. + if ( empty( $result ) ) { + return new WP_Error( + 'no_results', + esc_html__( 'No prompt was generated.', 'ai' ) + ); + } + + // Return the prompt in the format the Ability expects. + return sanitize_text_field( trim( $result ) ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + $post_id = isset( $args['context'] ) && is_numeric( $args['context'] ) ? absint( $args['context'] ) : null; + + if ( $post_id ) { + $post = get_post( $post_id ); + + // Ensure the post exists. + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), absint( $post_id ) ) + ); + } + + // Ensure the user has permission to edit this particular post. + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate excerpts for this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type = get_post_type( $post_id ); + + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + // Ensure the user has permission to edit posts in general. + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to generate image prompts.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'prompt', + ), + ); + } + + /** + * Generates an image generation prompt from the given content, context, and style. + * + * @since x.x.x + * + * @param string $content The content to use as inspiration for the final generated image. + * @param string|array $context The context to help generate the prompt. + * @param string $style The style instructions to apply to the final generated image. + * @return string|\WP_Error The generated image generation prompt, or a WP_Error if there was an error. + */ + protected function generate_prompt( string $content, $context, string $style ) { + // Convert the context to a string if it's an array. + if ( is_array( $context ) ) { + $context = implode( + "\n", + array_map( + static function ( $key, $value ) { + return sprintf( + '%s: %s', + ucwords( str_replace( '_', ' ', $key ) ), + $value + ); + }, + array_keys( $context ), + $context + ) + ); + } + + $content = '' . $content . ''; + + // If we have additional context, add it to the content. + if ( $context ) { + $content .= "\n\n" . $context . ''; + } + + // If we have style instructions, add them to the content. + if ( $style ) { + $content .= "\n\n'; + } + + // Generate the prompt using the AI client. + return AI_Client::prompt_with_wp_error( $content ) + ->using_system_instruction( $this->get_system_instruction( 'image-prompt-system-instruction.php' ) ) + ->using_temperature( 0.9 ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) + ->generate_text(); + } +} diff --git a/includes/Abilities/Image/Import_Base64_Image.php b/includes/Abilities/Image/Import_Base64_Image.php index 03a17b99..8bc1fbb2 100644 --- a/includes/Abilities/Image/Import_Base64_Image.php +++ b/includes/Abilities/Image/Import_Base64_Image.php @@ -60,6 +60,27 @@ protected function input_schema(): array { 'sanitize_callback' => 'sanitize_text_field', 'description' => esc_html__( 'The MIME type of the image.', 'ai' ), ), + 'meta' => array( + 'type' => 'array', + 'description' => esc_html__( 'Optional meta data to save with the image.', 'ai' ), + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'key' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'description' => esc_html__( 'The key of the meta data.', 'ai' ), + ), + 'value' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The value of the meta data.', 'ai' ), + ), + ), + 'required' => array( 'key', 'value' ), + 'additionalProperties' => false, + ), + ), ), 'required' => array( 'data' ), ); @@ -123,6 +144,7 @@ protected function execute_callback( $input ) { 'description' => '', 'alt_text' => '', 'mime_type' => null, + 'meta' => array(), ), ); @@ -171,6 +193,11 @@ protected function execute_callback( $input ) { return $result; } + // Save the meta data. + foreach ( $args['meta'] as $meta ) { + update_post_meta( $result['id'], sanitize_key( $meta['key'] ), sanitize_text_field( $meta['value'] ) ); + } + // Return the image data in the format the Ability expects. return array( 'image' => $result, diff --git a/includes/Abilities/Image/image-prompt-system-instruction.php b/includes/Abilities/Image/image-prompt-system-instruction.php new file mode 100644 index 00000000..59c984ec --- /dev/null +++ b/includes/Abilities/Image/image-prompt-system-instruction.php @@ -0,0 +1,39 @@ + array( $this, 'permission_callback' ), 'meta' => array( - 'mcp' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true, 'type' => 'tool', ), diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index e62e6d39..452ee117 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -10,8 +10,11 @@ namespace WordPress\AI\Experiments\Image_Generation; use WordPress\AI\Abilities\Image\Generate_Image as Image_Generation_Ability; +use WordPress\AI\Abilities\Image\Generate_Image_Prompt as Generate_Image_Prompt_Ability; use WordPress\AI\Abilities\Image\Import_Base64_Image as Image_Import_Ability; use WordPress\AI\Abstracts\Abstract_Experiment; +use WordPress\AI\Asset_Loader; +use WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation; if ( ! defined( 'ABSPATH' ) ) { exit; @@ -35,7 +38,7 @@ protected function load_experiment_metadata(): array { return array( 'id' => 'image-generation', 'label' => __( 'Image Generation', 'ai' ), - 'description' => __( 'Generates an image from a passed in prompt', 'ai' ), + 'description' => __( 'Generates a featured image from a generated image prompt', 'ai' ), ); } @@ -45,7 +48,26 @@ protected function load_experiment_metadata(): array { * @since 0.2.0 */ public function register(): void { + $this->register_post_meta(); add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Register any needed post meta. + * + * @since x.x.x + */ + public function register_post_meta(): void { + register_post_meta( + 'attachment', + 'ai_generated', + array( + 'type' => 'integer', + 'single' => true, + 'show_in_rest' => true, + ) + ); } /** @@ -71,5 +93,48 @@ public function register_abilities(): void { 'ability_class' => Image_Import_Ability::class, ), ); + + wp_register_ability( + 'ai/image-prompt-generation', + array( + 'label' => __( 'Image Prompt Generation', 'ai' ), + 'description' => __( 'Generates a prompt from post content that can be used to generate an image', 'ai' ), + 'ability_class' => Generate_Image_Prompt_Ability::class, + ), + ); + } + + /** + * Enqueues and localizes the admin script. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + // Load asset in new post and edit post screens only. + if ( 'post.php' !== $hook_suffix && 'post-new.php' !== $hook_suffix ) { + return; + } + + $screen = get_current_screen(); + + // Load the assets only if the post type supports featured images. + if ( + ! $screen || + ! post_type_supports( $screen->post_type, 'thumbnail' ) + ) { + return; + } + + Asset_Loader::enqueue_script( 'image_generation', 'experiments/image-generation' ); + Asset_Loader::localize_script( + 'image_generation', + 'ImageGenerationData', + array( + 'enabled' => $this->is_enabled(), + 'altTextEnabled' => ( new Alt_Text_Generation() )->is_enabled(), + ) + ); } } diff --git a/includes/helpers.php b/includes/helpers.php index 01e6a770..f604f78b 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -51,6 +51,9 @@ function normalize_content( string $content ): string { // Replace HTML linebreaks with newlines. $content = preg_replace( '##', "\n\n", $content ) ?? $content; + // Remove linebreaks but replace with spaces to avoid sentences running together. + $content = str_replace( array( "\r", "\n" ), ' ', (string) $content ); + // Strip all HTML tags. $content = wp_strip_all_tags( (string) $content ); @@ -225,6 +228,10 @@ function get_preferred_image_models(): array { 'google', 'imagen-4.0-generate-001', ), + array( + 'openai', + 'gpt-image-1.5', + ), array( 'openai', 'gpt-image-1', diff --git a/package-lock.json b/package-lock.json index b71e1ce0..1bd175e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@wordpress/blocks": "^15.12.0", "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", + "@wordpress/core-data": "^7.37.0", "@wordpress/data": "^10.34.0", "@wordpress/dom-ready": "^4.38.0", "@wordpress/edit-post": "^8.36.0", diff --git a/package.json b/package.json index 3ae62229..208f5db5 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@wordpress/blocks": "^15.12.0", "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", + "@wordpress/core-data": "^7.37.0", "@wordpress/data": "^10.34.0", "@wordpress/dom-ready": "^4.38.0", "@wordpress/edit-post": "^8.36.0", diff --git a/src/experiments/alt-text-generation/components/AltTextControls.tsx b/src/experiments/alt-text-generation/components/AltTextControls.tsx index 3ee89827..5dfdc986 100644 --- a/src/experiments/alt-text-generation/components/AltTextControls.tsx +++ b/src/experiments/alt-text-generation/components/AltTextControls.tsx @@ -11,28 +11,18 @@ import { Spinner, Notice, } from '@wordpress/components'; -import { - InspectorControls, - store as blockEditorStore, -} from '@wordpress/block-editor'; +import { InspectorControls } from '@wordpress/block-editor'; import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { dispatch, select } from '@wordpress/data'; import { store as noticesStore } from '@wordpress/notices'; import { store as editorStore } from '@wordpress/editor'; -/* eslint-disable import/no-extraneous-dependencies -- @wordpress/blocks is in dependencies; types are in devDependencies */ -import { serialize } from '@wordpress/blocks'; /** * Internal dependencies */ -import type { - AltTextGenerationAbilityInput, - ImageBlockAttributes, -} from '../types'; -import { runAbility } from '../../../utils/run-ability'; - -const IMAGE_PLACEHOLDER = '[[IMAGE_GOES_HERE]]'; +import type { ImageBlockAttributes } from '../types'; +import { generateAltText } from '../../../utils/generate-alt-text'; interface AltTextControlsProps { clientId: string; @@ -40,78 +30,6 @@ interface AltTextControlsProps { setAttributes: ( attributes: Partial< ImageBlockAttributes > ) => void; } -/** - * Replaces the current image block markup in post content with a placeholder. - * - * @param {string} content Full post content. - * @param {string} clientId Client ID of the current image block. - * @return {string} Content with this image block replaced by the placeholder. - */ -function replaceImageBlockWithPlaceholder( - content: string, - clientId: string -): string { - // eslint-disable-next-line dot-notation -- getBlock from store index signature - const block = select( blockEditorStore )[ 'getBlock' ]( clientId ); - if ( ! block ) { - return content; - } - - const serializedBlock = serialize( block ); - if ( ! serializedBlock || ! content.includes( serializedBlock ) ) { - return content; - } - - return content.replace( serializedBlock, IMAGE_PLACEHOLDER ); -} - -/** - * Generates alt text for an image using the AI ability. - * - * @param {number|undefined} attachmentId The attachment ID. - * @param {string|undefined} imageUrl The image URL (fallback if no attachment ID). - * @param {string|undefined} content The content of the post. - * @param {string|undefined} clientId The client ID of the current image block. - * @return {Promise} The generated alt text. - */ -async function generateAltText( - attachmentId: number | undefined, - imageUrl: string | undefined, - content: string | undefined, - clientId: string | undefined -): Promise< string > { - const params: AltTextGenerationAbilityInput = {}; - - if ( attachmentId ) { - params.attachment_id = attachmentId; - } else if ( imageUrl ) { - params.image_url = imageUrl; - } else { - throw new Error( - __( 'No image available to generate alt text for.', 'ai' ) - ); - } - - if ( content ) { - // Replace the image block with the placeholder. - const contentWithPlaceholder = - clientId !== undefined - ? replaceImageBlockWithPlaceholder( content, clientId ) - : content; - - // Prepare the context. - params.context = `What follows is the full article content, where the image has been replaced with the placeholder ${ IMAGE_PLACEHOLDER }. Use the surrounding text to understand the purpose, subject, and relevance of the image within the article. Be sure to describe only information not already conveyed in nearby text. CONTENT: \n\n${ contentWithPlaceholder }`; - } - - const response = await runAbility( 'ai/alt-text-generation', params ); - - if ( response && typeof response === 'object' && 'alt_text' in response ) { - return response.alt_text as string; - } - - throw new Error( __( 'Failed to generate alt text.', 'ai' ) ); -} - /** * Returns the appropriate button label based on state. * diff --git a/src/experiments/image-generation/components/AILabel.tsx b/src/experiments/image-generation/components/AILabel.tsx new file mode 100644 index 00000000..17226bcc --- /dev/null +++ b/src/experiments/image-generation/components/AILabel.tsx @@ -0,0 +1,63 @@ +/** + * WordPress dependencies + */ +import { store as coreStore } from '@wordpress/core-data'; +import { select, useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { AILabelProps } from '../types'; + +/** + * Displays a label indicating that the image was generated by AI. + * + * @param {AILabelProps} props - Component props. + * @return {JSX.Element} The AILabel component. + */ +export default function AILabel( { label }: AILabelProps ): JSX.Element { + const featuredImageId = + select( editorStore ).getEditedPostAttribute( 'featured_media' ); + const [ image, setImage ] = useState< any >( null ); + + // Get the meta data from the featured image ID. + const featuredImage = useSelect( + ( selectStore ) => { + if ( ! featuredImageId ) { + return null; + } + return selectStore( coreStore ).getEntityRecord( + 'postType', + 'attachment', + featuredImageId + ); + }, + [ featuredImageId ] + ); + + // Sync image state when entity record becomes available. + useEffect( () => { + if ( featuredImage ) { + setImage( featuredImage ); + } else if ( ! featuredImageId ) { + setImage( '' ); + } + }, [ featuredImage, featuredImageId ] ); + + return ( + <> + { image && image?.meta?.ai_generated === 1 && ( +
+ + { label } + +
+ ) } + + ); +} diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx new file mode 100644 index 00000000..ce3454f7 --- /dev/null +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -0,0 +1,107 @@ +/** + * WordPress dependencies + */ +import { Button, Spinner } from '@wordpress/components'; +import { dispatch, select, useDispatch } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; + +/** + * Internal dependencies + */ +import { generateImage } from '../functions/generate-image'; +import { uploadImage } from '../functions/upload-image'; + +/** + * GenerateFeaturedImage component. + * + * Provides a button to generate a featured image. + * + * @return {JSX.Element} The GenerateFeaturedImage component. + */ +export default function GenerateFeaturedImage(): JSX.Element { + const { editPost } = useDispatch( editorStore ); + + const content = select( editorStore ).getEditedPostContent(); + const postId = select( editorStore ).getCurrentPostId(); + const featuredImage = + select( editorStore ).getEditedPostAttribute( 'featured_media' ); + + const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); + const [ progressMessage, setProgressMessage ] = useState< string | null >( + null + ); + + const buttonLabel = featuredImage + ? __( 'Generate new featured image', 'ai' ) + : __( 'Generate featured image', 'ai' ); + + /** + * Handles the generate button click. + */ + const handleGenerate = async () => { + setIsGenerating( true ); + setProgressMessage( null ); + ( dispatch( noticesStore ) as any ).removeNotice( + 'ai_image_generation_error' + ); + + try { + const generatedImageData = await generateImage( + postId as number, + content, + { onProgress: setProgressMessage } + ); + const importedImage = await uploadImage( generatedImageData, { + onProgress: setProgressMessage, + } ); + editPost( { + featured_media: importedImage.id, + } ); + } catch ( error: any ) { + ( dispatch( noticesStore ) as any ).createErrorNotice( error, { + id: 'ai_image_generation_error', + isDismissible: true, + } ); + } finally { + setIsGenerating( false ); + setProgressMessage( null ); + } + }; + + return ( +
+
+ + { progressMessage && ( +
+ { progressMessage } + +
+ ) } +
+
+ ); +} diff --git a/src/experiments/image-generation/featured-image.tsx b/src/experiments/image-generation/featured-image.tsx new file mode 100644 index 00000000..0b8c1705 --- /dev/null +++ b/src/experiments/image-generation/featured-image.tsx @@ -0,0 +1,49 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { createElement } from '@wordpress/element'; +import { addFilter } from '@wordpress/hooks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import GenerateFeaturedImage from './components/GenerateFeaturedImage'; +import AILabel from './components/AILabel'; + +const { aiImageGenerationData } = window as any; + +/** + * Wraps the PostFeaturedImage component to add a generate featured image button. + * + * @param OriginalComponent - The original PostFeaturedImage component. + * @return The wrapped component. + */ +function wrapPostFeaturedImage( + OriginalComponent: React.ComponentType< any > +) { + if ( ! aiImageGenerationData.enabled ) { + return OriginalComponent; + } + + return function ( props: any ) { + return createElement( + React.Fragment, + {}, + , + createElement( OriginalComponent, props ), + + ); + }; +} + +addFilter( + 'editor.PostFeaturedImage', + 'ai/image-generation', + wrapPostFeaturedImage +); diff --git a/src/experiments/image-generation/functions/format-context.ts b/src/experiments/image-generation/functions/format-context.ts new file mode 100644 index 00000000..77d68e86 --- /dev/null +++ b/src/experiments/image-generation/functions/format-context.ts @@ -0,0 +1,26 @@ +/** + * Internal dependencies + */ +import type { ContextRecord } from '../types'; + +/** + * Formats an object as a string with "Key: Value" pairs on separate lines. + * + * @param {ContextRecord} obj The object to format. + * @return {string} The formatted string. + */ +export function formatContext( obj: ContextRecord ): string { + return Object.entries( obj ) + .filter( + ( [ , value ] ) => + value !== undefined && value !== null && value !== '' + ) + .map( ( [ key, value ] ) => { + // Capitalize first letter of key and replace underscores with spaces + const formattedKey = key + .replace( /_/g, ' ' ) + .replace( /(?:^|\s)\S/g, ( char ) => char.toUpperCase() ); + return `${ formattedKey }: ${ value }`; + } ) + .join( '\n' ); +} diff --git a/src/experiments/image-generation/functions/generate-image.ts b/src/experiments/image-generation/functions/generate-image.ts new file mode 100644 index 00000000..dec0431f --- /dev/null +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -0,0 +1,76 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { formatContext } from './format-context'; +import { getContext } from './get-context'; +import { generatePrompt } from './generate-prompt'; +import { runAbility } from '../../../utils/run-ability'; +import type { + GeneratedImageData, + ImageGenerationAbilityInput, + ImageProgressCallback, + PostContext, +} from '../types'; + +/** + * Generates an image for the given post ID and content. + * + * @param {number} postId The ID of the post to generate a featured image for. + * @param {string} content The content of the post to generate an image for. + * @param {Object} options Optional settings. + * @param {Function} options.onProgress Callback invoked with progress messages. + * @return {Promise} A promise that resolves to the generated image data. + */ +export async function generateImage( + postId: number, + content: string, + options?: { onProgress?: ImageProgressCallback } +): Promise< GeneratedImageData > { + const onProgress = options?.onProgress; + + let context: PostContext; + + try { + context = ( await getContext( postId ) ) as PostContext; + } catch ( error: any ) { + throw new Error( + `Failed to get post context: ${ error.message || error }` + ); + } + + let prompt: string; + + try { + onProgress?.( __( 'Generating image prompt', 'ai' ) ); + prompt = await generatePrompt( content, formatContext( context ) ); + } catch ( error: any ) { + throw new Error( + `Failed to generate prompt: ${ error.message || error }` + ); + } + + onProgress?.( __( 'Generating image', 'ai' ) ); + + const params: ImageGenerationAbilityInput = { + prompt, + }; + + return runAbility< GeneratedImageData >( 'ai/image-generation', params ) + .then( ( response ) => { + if ( response && typeof response === 'object' ) { + const result = response as { prompt?: string }; + result.prompt = prompt; + return result as GeneratedImageData; + } + + throw new Error( 'Invalid response from generate image' ); + } ) + .catch( ( error ) => { + throw new Error( error.message ); + } ); +} diff --git a/src/experiments/image-generation/functions/generate-prompt.ts b/src/experiments/image-generation/functions/generate-prompt.ts new file mode 100644 index 00000000..5ae77d49 --- /dev/null +++ b/src/experiments/image-generation/functions/generate-prompt.ts @@ -0,0 +1,34 @@ +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import type { ImagePromptGenerationAbilityInput } from '../types'; + +/** + * Generates a featured image generation prompt for the given post ID and content. + * + * @param {string} content The content to use as inspiration for the generated image. + * @param {string} context The context to help generate the prompt. + * @return {Promise} A promise that resolves to the generated featured image prompt. + */ +export async function generatePrompt( + content: string, + context: string +): Promise< string > { + const params: ImagePromptGenerationAbilityInput = { + content, + context, + }; + + return await runAbility( 'ai/image-prompt-generation', params ) + .then( ( response ) => { + if ( response && typeof response === 'string' ) { + return response; + } + + return ''; + } ) + .catch( ( error ) => { + throw new Error( error.message ); + } ); +} diff --git a/src/experiments/image-generation/functions/get-context.ts b/src/experiments/image-generation/functions/get-context.ts new file mode 100644 index 00000000..9317f671 --- /dev/null +++ b/src/experiments/image-generation/functions/get-context.ts @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import type { GetPostDetailsAbilityInput, PostContext } from '../types'; + +/** + * Gets the context for the given post ID. + * + * @param {number} postId The ID of the post to get the context for. + * @return {Promise} A promise that resolves to the context. + */ +export async function getContext( postId: number ): Promise< PostContext > { + const params: GetPostDetailsAbilityInput = { + post_id: postId, + fields: [ 'title', 'type' ], + }; + + return await runAbility< PostContext >( 'ai/get-post-details', params ) + .then( ( response ) => { + if ( response && typeof response === 'object' ) { + return response as PostContext; + } + + throw new Error( 'Invalid response from get context' ); + } ) + .catch( ( error ) => { + throw new Error( error.message ); + } ); +} diff --git a/src/experiments/image-generation/functions/upload-image.ts b/src/experiments/image-generation/functions/upload-image.ts new file mode 100644 index 00000000..2a174f90 --- /dev/null +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -0,0 +1,90 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { generateAltText } from '../../../utils/generate-alt-text'; +import { runAbility } from '../../../utils/run-ability'; +import { trimText } from '../../../utils/text'; +import type { + GeneratedImageData, + ImageImportAbilityInput, + ImageProgressCallback, + UploadedImage, +} from '../types'; + +const { aiImageGenerationData } = window as any; + +/** + * Uploads an image to the media library. + * + * @param {GeneratedImageData} imageData The generated image data (from generateImage). + * @param {Object} options Optional settings. + * @param {Function} options.onProgress Callback invoked with progress messages. + * @return {Promise} A promise that resolves to the uploaded image data. + */ +export async function uploadImage( + { image, prompt }: GeneratedImageData, + options?: { onProgress?: ImageProgressCallback } +): Promise< UploadedImage > { + const onProgress = options?.onProgress; + + const params: ImageImportAbilityInput = { + data: image.data, + mime_type: 'image/png', + description: sprintf( + /* translators: 1: Provider name, 2: Model name, 3: Date, 4: Prompt */ + __( 'Generated by %1$s using %2$s on %3$s. Prompt: %4$s', 'ai' ), + image.provider_metadata.name, + image.model_metadata.name, + new Date().toLocaleDateString(), + prompt + ), + meta: [ + { + key: 'ai_generated', + value: '1', + }, + ], + }; + + // Use the prompt as alt text by default. + params.alt_text = prompt; + + // If alt text generation is enabled, try generating alt text. + if ( aiImageGenerationData?.altTextEnabled ) { + try { + onProgress?.( __( 'Generating alt text', 'ai' ) ); + params.alt_text = await generateAltText( + undefined, + `data:image/png;base64,${ image.data }` + ); + } catch ( error ) { + params.alt_text = prompt; + } + } + + // Set our image title to be a trimmed version of the alt text. + params.title = trimText( params.alt_text ); + + onProgress?.( __( 'Importing image', 'ai' ) ); + + return await runAbility( 'ai/image-import', params ) + .then( ( response: any ) => { + if ( + response && + typeof response === 'object' && + 'image' in response + ) { + return response.image as UploadedImage; + } + + throw new Error( 'Invalid response from image import' ); + } ) + .catch( ( error ) => { + throw new Error( error.message ); + } ); +} diff --git a/src/experiments/image-generation/index.ts b/src/experiments/image-generation/index.ts new file mode 100644 index 00000000..6f287181 --- /dev/null +++ b/src/experiments/image-generation/index.ts @@ -0,0 +1,8 @@ +/** + * Image generation experiment. + */ + +/** + * Internal dependencies + */ +import './featured-image'; diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts new file mode 100644 index 00000000..37856b8b --- /dev/null +++ b/src/experiments/image-generation/types.ts @@ -0,0 +1,121 @@ +/** + * Type definitions for image generation experiment. + */ + +/** + * Provider metadata from image generation API. + */ +export interface ProviderMetadata { + id: string; + name: string; + type: string; +} + +/** + * Model metadata from image generation API. + */ +export interface ModelMetadata { + id: string; + name: string; +} + +/** + * Generated image data (image part of generation result). + */ +export interface GeneratedImage { + data: string; + provider_metadata: ProviderMetadata; + model_metadata: ModelMetadata; +} + +/** + * Result from generateImage / input for uploadImage. + */ +export interface GeneratedImageData { + image: GeneratedImage; + prompt: string; +} + +/** + * Result from uploadImage (imported image in media library). + */ +export interface UploadedImage { + id: number; + url: string; + title: string; +} + +/** + * Post context from getContext (title, type, optional content). + */ +export interface PostContext { + title: string; + type: string; + content?: string; + [ key: string ]: string | undefined; +} + +/** + * Object shape for formatContext (key-value record). + */ +export type ContextRecord = Record< string, string | undefined >; + +/** + * Props for the AILabel component. + */ +export interface AILabelProps { + label: string; +} + +/** + * Input parameters for the ai/image-import ability. + */ +export interface ImageImportAbilityInput { + data: string; + filename?: string; + title?: string; + description?: string; + alt_text?: string; + mime_type?: string; + meta?: { + key: string; + value: string; + }[]; + [ key: string ]: + | string + | number + | { key: string; value: string }[] + | undefined; +} + +/** + * Input parameters for the ai/image-generation ability. + */ +export interface ImageGenerationAbilityInput { + prompt: string; + [ key: string ]: string | undefined; +} + +/** + * Input parameters for the ai/image-prompt-generation ability. + */ +export interface ImagePromptGenerationAbilityInput { + content: string; + context?: string; + style?: string; + [ key: string ]: string | undefined; +} + +/** + * Input parameters for the ai/get-post-details ability. + */ +export interface GetPostDetailsAbilityInput { + post_id: number; + fields?: string[]; + [ key: string ]: string | number | string[] | undefined; +} + +/** + * Callback type for image generation progress messages. + */ +export type ImageProgressCallback = ( message: string ) => void; diff --git a/src/utils/generate-alt-text.ts b/src/utils/generate-alt-text.ts new file mode 100644 index 00000000..799fad3b --- /dev/null +++ b/src/utils/generate-alt-text.ts @@ -0,0 +1,88 @@ +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { select } from '@wordpress/data'; +/* eslint-disable import/no-extraneous-dependencies -- @wordpress/blocks is in dependencies; types are in devDependencies */ +import { serialize } from '@wordpress/blocks'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { runAbility } from './run-ability'; +import type { AltTextGenerationAbilityInput } from '../experiments/alt-text-generation/types'; + +const IMAGE_PLACEHOLDER = '[[IMAGE_GOES_HERE]]'; + +/** + * Replaces the current image block markup in post content with a placeholder. + * + * @param {string} content Full post content. + * @param {string} clientId Client ID of the current image block. + * @return {string} Content with this image block replaced by the placeholder. + */ +function replaceImageBlockWithPlaceholder( + content: string, + clientId: string +): string { + // eslint-disable-next-line dot-notation -- getBlock from store index signature + const block = select( blockEditorStore )[ 'getBlock' ]( clientId ); + if ( ! block ) { + return content; + } + + const serializedBlock = serialize( block ); + if ( ! serializedBlock || ! content.includes( serializedBlock ) ) { + return content; + } + + return content.replace( serializedBlock, IMAGE_PLACEHOLDER ); +} + +/** + * Generates alt text for an image using the AI ability. + * + * @param {number|undefined} attachmentId The attachment ID. + * @param {string|undefined} imageUrl The image URL (fallback if no attachment ID). + * @param {string|undefined} content The content of the post. + * @param {string|undefined} clientId The client ID of the current image block. + * @return {Promise} The generated alt text. + */ +export async function generateAltText( + attachmentId?: number | undefined, + imageUrl?: string | undefined, + content?: string | undefined, + clientId?: string | undefined +): Promise< string > { + const params: AltTextGenerationAbilityInput = {}; + + if ( attachmentId ) { + params.attachment_id = attachmentId; + } else if ( imageUrl ) { + params.image_url = imageUrl; + } else { + throw new Error( + __( 'No image available to generate alt text for.', 'ai' ) + ); + } + + if ( content ) { + // Replace the image block with the placeholder. + const contentWithPlaceholder = + clientId !== undefined + ? replaceImageBlockWithPlaceholder( content, clientId ) + : content; + + // Prepare the context. + params.context = `What follows is the full article content, where the image has been replaced with the placeholder ${ IMAGE_PLACEHOLDER }. Use the surrounding text to understand the purpose, subject, and relevance of the image within the article. Be sure to describe only information not already conveyed in nearby text. CONTENT: \n\n${ contentWithPlaceholder }`; + } + + const response = await runAbility( 'ai/alt-text-generation', params ); + + if ( response && typeof response === 'object' && 'alt_text' in response ) { + return response.alt_text as string; + } + + throw new Error( __( 'Failed to generate alt text.', 'ai' ) ); +} diff --git a/src/utils/text.ts b/src/utils/text.ts new file mode 100644 index 00000000..258d6055 --- /dev/null +++ b/src/utils/text.ts @@ -0,0 +1,25 @@ +/** + * Collection of text utilities. + */ + +/** + * Trims a string to a given length, truncating at word boundaries. + * + * @param {string} text The text to trim. + * @param {number} length The maximum length of the text. + * @return {string} The trimmed text. + */ +export function trimText( text: string, length: number = 80 ): string { + if ( text.length <= length ) { + return text; + } + + // Try to truncate at word boundary + const truncated = text.substring( 0, length ); + const lastSpace = truncated.lastIndexOf( ' ' ); + + // Use word boundary if it's not too short (at least 50% of length) + return lastSpace > length * 0.5 + ? truncated.substring( 0, lastSpace ) + : truncated; +} diff --git a/tests/Integration/Includes/Abilities/Image_GenerationTest.php b/tests/Integration/Includes/Abilities/Image_GenerationTest.php index 60e0a03e..e6447807 100644 --- a/tests/Integration/Includes/Abilities/Image_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Image_GenerationTest.php @@ -145,8 +145,16 @@ public function test_output_schema_returns_expected_structure() { $schema = $method->invoke( $this->ability ); $this->assertIsArray( $schema, 'Output schema should be an array' ); - $this->assertEquals( 'string', $schema['type'], 'Schema type should be string' ); - $this->assertArrayHasKey( 'description', $schema, 'Schema should have description' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'image', $schema['properties'], 'Schema should have image property' ); + + $image_schema = $schema['properties']['image']; + $this->assertEquals( 'object', $image_schema['type'], 'Image property should be object type' ); + $this->assertArrayHasKey( 'properties', $image_schema, 'Image should have properties' ); + $this->assertArrayHasKey( 'data', $image_schema['properties'], 'Image should have data property' ); + $this->assertArrayHasKey( 'provider_metadata', $image_schema['properties'], 'Image should have provider_metadata property' ); + $this->assertArrayHasKey( 'model_metadata', $image_schema['properties'], 'Image should have model_metadata property' ); } /** @@ -170,14 +178,17 @@ public function test_execute_callback_with_prompt() { return; } - // Result may be string (success) or WP_Error (if AI client unavailable). + // Result may be array with image (success) or WP_Error (if AI client unavailable). if ( is_wp_error( $result ) ) { $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); return; } - $this->assertIsString( $result, 'Result should be a string' ); - $this->assertNotEmpty( $result, 'Result should not be empty' ); + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'image', $result, 'Result should have image key' ); + $this->assertIsArray( $result['image'], 'Result image should be an array' ); + $this->assertArrayHasKey( 'data', $result['image'], 'Result image should have data' ); + $this->assertNotEmpty( $result['image']['data'], 'Result image data should not be empty' ); } /** @@ -201,7 +212,7 @@ public function test_execute_callback_with_empty_prompt() { return; } - // Result may be string (success) or WP_Error (if AI client unavailable or no results). + // Result may be array with image (success) or WP_Error (if AI client unavailable or no results). if ( is_wp_error( $result ) ) { // If it's an error about no results, verify the error code. if ( 'no_results' === $result->get_error_code() ) { @@ -213,9 +224,10 @@ public function test_execute_callback_with_empty_prompt() { return; } - // If we get a result, it should be a non-empty string. - $this->assertIsString( $result, 'Result should be a string' ); - $this->assertNotEmpty( $result, 'Result should not be empty' ); + // If we get a result, it should be an array with image data. + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'image', $result, 'Result should have image key' ); + $this->assertNotEmpty( $result['image']['data'] ?? '', 'Result image data should not be empty' ); } /** @@ -239,7 +251,7 @@ public function test_execute_callback_handles_empty_result() { return; } - // Result may be string (success) or WP_Error (if AI client unavailable or no results). + // Result may be array with image (success) or WP_Error (if AI client unavailable or no results). if ( is_wp_error( $result ) ) { // If it's an error about no results, verify the error code. if ( 'no_results' === $result->get_error_code() ) { @@ -251,9 +263,10 @@ public function test_execute_callback_handles_empty_result() { return; } - // If we get a result, it should be a non-empty string. - $this->assertIsString( $result, 'Result should be a string' ); - $this->assertNotEmpty( $result, 'Result should not be empty' ); + // If we get a result, it should be an array with image data. + $this->assertIsArray( $result, 'Result should be an array' ); + $this->assertArrayHasKey( 'image', $result, 'Result should have image key' ); + $this->assertNotEmpty( $result['image']['data'] ?? '', 'Result image data should not be empty' ); } /** diff --git a/tests/Integration/Includes/Abilities/Image_ImportTest.php b/tests/Integration/Includes/Abilities/Image_ImportTest.php index 3751f748..be0bacca 100644 --- a/tests/Integration/Includes/Abilities/Image_ImportTest.php +++ b/tests/Integration/Includes/Abilities/Image_ImportTest.php @@ -41,7 +41,6 @@ protected function load_experiment_metadata(): array { public function register(): void { // No-op for testing. } - } /** @@ -136,6 +135,7 @@ public function test_input_schema_returns_expected_structure() { $this->assertArrayHasKey( 'description', $schema['properties'], 'Schema should have description property' ); $this->assertArrayHasKey( 'alt_text', $schema['properties'], 'Schema should have alt_text property' ); $this->assertArrayHasKey( 'mime_type', $schema['properties'], 'Schema should have mime_type property' ); + $this->assertArrayHasKey( 'meta', $schema['properties'], 'Schema should have meta property' ); $this->assertArrayHasKey( 'required', $schema, 'Schema should have required array' ); $this->assertContains( 'data', $schema['required'], 'Data should be required' ); @@ -149,6 +149,16 @@ public function test_input_schema_returns_expected_structure() { $this->assertEquals( 'string', $schema['properties']['description']['type'], 'Description should be string type' ); $this->assertEquals( 'string', $schema['properties']['alt_text']['type'], 'Alt text should be string type' ); $this->assertEquals( 'string', $schema['properties']['mime_type']['type'], 'MIME type should be string type' ); + $this->assertEquals( 'array', $schema['properties']['meta']['type'], 'Meta should be array type' ); + $this->assertEquals( 'object', $schema['properties']['meta']['items']['type'], 'Meta items should be object type' ); + $this->assertArrayHasKey( 'key', $schema['properties']['meta']['items']['properties'], 'Meta should have key property' ); + $this->assertArrayHasKey( 'value', $schema['properties']['meta']['items']['properties'], 'Meta should have value property' ); + $this->assertEquals( 'string', $schema['properties']['meta']['items']['properties']['key']['type'], 'Key should be string type' ); + $this->assertEquals( 'string', $schema['properties']['meta']['items']['properties']['value']['type'], 'Value should be string type' ); + $this->assertArrayHasKey( 'required', $schema['properties']['meta']['items'], 'Meta items should have required array' ); + $this->assertContains( 'key', $schema['properties']['meta']['items']['required'], 'Key should be required' ); + $this->assertContains( 'value', $schema['properties']['meta']['items']['required'], 'Value should be required' ); + $this->assertArrayHasKey( 'additionalProperties', $schema['properties']['meta']['items'], 'Meta items should have additionalProperties' ); } /** @@ -234,6 +244,12 @@ public function test_execute_callback_with_custom_metadata() { 'description' => 'This is a custom test image description', 'alt_text' => 'Custom Test Image Alt Text', 'mime_type' => 'image/png', + 'meta' => array( + array( + 'key' => 'custom_meta_key', + 'value' => 'custom_meta_value', + ), + ), ); $result = $method->invoke( $this->ability, $input ); @@ -251,6 +267,9 @@ public function test_execute_callback_with_custom_metadata() { $this->assertEquals( 'Custom Test Image', $attachment->post_title, 'Attachment title should match' ); $this->assertEquals( 'This is a custom test image description', $attachment->post_content, 'Attachment description should match' ); $this->assertEquals( 'Custom Test Image Alt Text', get_post_meta( $result['image']['id'], '_wp_attachment_image_alt', true ), 'Attachment alt text should match' ); + + // Verify the meta data was saved. + $this->assertEquals( 'custom_meta_value', get_post_meta( $result['image']['id'], 'custom_meta_key', true ), 'Meta data should be saved' ); } /** diff --git a/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php b/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php new file mode 100644 index 00000000..49c05e84 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php @@ -0,0 +1,504 @@ + 'image-prompt-generation', + 'label' => 'Image Prompt Generation', + 'description' => 'Generates a prompt from post content that can be used to generate an image', + ); + } + + /** + * Registers the experiment. + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Image_Prompt_Generation Ability test case. + * + * @since x.x.x + */ +class Image_Prompt_GenerationTest extends WP_UnitTestCase { + + /** + * Image_Prompt_Generation ability instance. + * + * @var Generate_Image_Prompt + */ + private $ability; + + /** + * Test experiment instance. + * + * @var Test_Image_Prompt_Generation_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Image_Prompt_Generation_Experiment(); + $this->ability = new Generate_Image_Prompt( + 'ai/image-prompt-generation', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since x.x.x + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertEquals( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Input schema should be an array' ); + $this->assertEquals( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'properties', $schema, 'Schema should have properties' ); + $this->assertArrayHasKey( 'content', $schema['properties'], 'Schema should have content property' ); + $this->assertArrayHasKey( 'context', $schema['properties'], 'Schema should have context property' ); + $this->assertArrayHasKey( 'style', $schema['properties'], 'Schema should have style property' ); + $this->assertArrayHasKey( 'required', $schema, 'Schema should have required array' ); + $this->assertContains( 'content', $schema['required'], 'Content should be required' ); + + // Verify content property. + $this->assertEquals( 'string', $schema['properties']['content']['type'], 'Content should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['content']['sanitize_callback'], 'Content should use sanitize_text_field' ); + + // Verify context property. + $this->assertEquals( 'string', $schema['properties']['context']['type'], 'Context should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['context']['sanitize_callback'], 'Context should use sanitize_text_field' ); + + // Verify style property. + $this->assertEquals( 'string', $schema['properties']['style']['type'], 'Style should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['style']['sanitize_callback'], 'Style should use sanitize_text_field' ); + } + + /** + * Test that output_schema() returns the expected schema structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema, 'Output schema should be an array' ); + $this->assertEquals( 'string', $schema['type'], 'Schema type should be string' ); + $this->assertArrayHasKey( 'description', $schema, 'Schema should have description' ); + $this->assertEquals( 'The image generation prompt.', $schema['description'], 'Description should match' ); + } + + /** + * Test that execute_callback() handles content parameter correctly. + * + * @since x.x.x + */ + public function test_execute_callback_with_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This article discusses the benefits of renewable energy and solar power installations.', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be string (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that execute_callback() handles content with context. + * + * @since x.x.x + */ + public function test_execute_callback_with_content_and_context() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This article discusses modern office design trends.', + 'context' => 'Title: Modern Office Design\nType: post', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be string (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that execute_callback() handles content with context and style. + * + * @since x.x.x + */ + public function test_execute_callback_with_content_context_and_style() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'This article discusses modern office design trends.', + 'context' => 'Title: Modern Office Design\nType: post', + 'style' => 'Editorial style, professional photography', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be string (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that execute_callback() handles post ID in context. + * + * @since x.x.x + */ + public function test_execute_callback_with_post_id_context() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'This article discusses the benefits of renewable energy.', + 'post_title' => 'Renewable Energy Solutions', + ) + ); + + $input = array( + 'context' => (string) $post_id, + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be string (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that execute_callback() returns error when post ID is invalid. + * + * @since x.x.x + */ + public function test_execute_callback_with_invalid_post_id() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'context' => '99999', // Non-existent post ID. + ); + + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'post_not_found', $result->get_error_code(), 'Error code should be post_not_found' ); + } + + /** + * Test that execute_callback() returns error when content is missing. + * + * @since x.x.x + */ + public function test_execute_callback_without_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array(); + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() returns error when content is empty. + * + * @since x.x.x + */ + public function test_execute_callback_with_empty_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => '', + ); + + $result = $method->invoke( $this->ability, $input ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Result should be WP_Error' ); + $this->assertEquals( 'content_not_provided', $result->get_error_code(), 'Error code should be content_not_provided' ); + } + + /** + * Test that execute_callback() uses default values for optional parameters. + * + * @since x.x.x + */ + public function test_execute_callback_uses_default_values() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $input = array( + 'content' => 'Test content', + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be string (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + } + + /** + * Test that execute_callback() prioritizes passed content over post content. + * + * @since x.x.x + */ + public function test_execute_callback_content_overrides_post_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + // Create a test post. + $post_id = $this->factory->post->create( + array( + 'post_content' => 'Post content that should be ignored.', + 'post_title' => 'Test Post', + ) + ); + + $input = array( + 'content' => 'This content should be used instead of post content.', + 'context' => (string) $post_id, + ); + + try { + $result = $method->invoke( $this->ability, $input ); + } catch ( \Throwable $e ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $e->getMessage() ); + return; + } + + // Result may be string (success) or WP_Error (if AI client unavailable). + if ( is_wp_error( $result ) ) { + $this->markTestSkipped( 'AI client not available in test environment: ' . $result->get_error_message() ); + return; + } + + $this->assertIsString( $result, 'Result should be a string' ); + $this->assertNotEmpty( $result, 'Result should not be empty' ); + } + + /** + * Test that permission_callback() returns true for user with edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_returns_true_for_logged_in_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // User must have edit_posts capability when no post context is provided. + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); + } + + /** + * Test that permission_callback() returns WP_Error for logged out user. + * + * @since x.x.x + */ + public function test_permission_callback_returns_false_for_logged_out_user() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Ensure no user is logged in. + wp_set_current_user( 0 ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Permission should be denied with WP_Error for logged out user' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that permission_callback() returns WP_Error for user without edit_posts capability. + * + * @since x.x.x + */ + public function test_permission_callback_returns_error_for_user_without_edit_posts() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + // Subscriber role does not have edit_posts capability. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result, 'Permission should be denied with WP_Error for user without edit_posts' ); + $this->assertEquals( 'insufficient_capabilities', $result->get_error_code(), 'Error code should be insufficient_capabilities' ); + } + + /** + * Test that meta() returns the expected meta structure. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta, 'Meta should be an array' ); + $this->assertArrayHasKey( 'show_in_rest', $meta, 'Meta should have show_in_rest' ); + $this->assertTrue( $meta['show_in_rest'], 'show_in_rest should be true' ); + $this->assertArrayHasKey( 'mcp', $meta, 'Meta should have mcp' ); + $this->assertIsArray( $meta['mcp'], 'mcp should be an array' ); + $this->assertArrayHasKey( 'public', $meta['mcp'], 'mcp should have public' ); + $this->assertTrue( $meta['mcp']['public'], 'mcp public should be true' ); + $this->assertArrayHasKey( 'type', $meta['mcp'], 'mcp should have type' ); + $this->assertEquals( 'prompt', $meta['mcp']['type'], 'mcp type should be prompt' ); + } +} diff --git a/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php b/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php index c0189098..49e8954e 100644 --- a/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php +++ b/tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php @@ -71,5 +71,54 @@ public function test_experiment_registration() { $this->assertEquals( 'Image Generation', $experiment->get_label() ); $this->assertTrue( $experiment->is_enabled() ); } + + /** + * Test that the experiment registers all abilities. + * + * @since x.x.x + */ + public function test_experiment_registers_abilities() { + if ( ! function_exists( 'wp_get_ability' ) ) { + $this->markTestSkipped( 'WP_Ability class not available' ); + return; + } + + // Expect warnings about already registered abilities from other tests. + $this->setExpectedIncorrectUsage( 'WP_Abilities_Registry::register' ); + + // Trigger the hook to register abilities. + do_action( 'wp_abilities_api_init' ); + + // Verify image generation ability is registered. + $image_generation_ability = wp_get_ability( 'ai/image-generation' ); + $this->assertNotNull( $image_generation_ability, 'Image generation ability should be registered' ); + $this->assertInstanceOf( \WP_Ability::class, $image_generation_ability, 'Should be a WP_Ability instance' ); + + // Verify image import ability is registered. + $image_import_ability = wp_get_ability( 'ai/image-import' ); + $this->assertNotNull( $image_import_ability, 'Image import ability should be registered' ); + $this->assertInstanceOf( \WP_Ability::class, $image_import_ability, 'Should be a WP_Ability instance' ); + + // Verify image prompt generation ability is registered. + $image_prompt_ability = wp_get_ability( 'ai/image-prompt-generation' ); + $this->assertNotNull( $image_prompt_ability, 'Image prompt generation ability should be registered' ); + $this->assertInstanceOf( \WP_Ability::class, $image_prompt_ability, 'Should be a WP_Ability instance' ); + } + + /** + * Test that the experiment registers post meta. + * + * @since x.x.x + */ + public function test_experiment_registers_post_meta() { + $experiment = new Image_Generation(); + $experiment->register(); + + // Verify post meta is registered for attachment post type. + $meta = get_registered_meta_keys( 'post', 'attachment' ); + $this->assertArrayHasKey( 'ai_generated', $meta, 'ai_generated meta should be registered for attachment post type' ); + $this->assertEquals( 'integer', $meta['ai_generated']['type'], 'ai_generated meta type should be integer' ); + $this->assertTrue( $meta['ai_generated']['show_in_rest'], 'ai_generated meta should be available in REST API' ); + } } diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index 4a27190f..b700637f 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -53,7 +53,7 @@ public function test_normalize_content_strips_html_entities() { } /** - * Test that normalize_content() replaces HTML linebreaks with newlines. + * Test that normalize_content() replaces HTML linebreaks and removes linebreaks. * * @since 0.1.0 */ @@ -62,7 +62,30 @@ public function test_normalize_content_replaces_linebreaks() { $result = \WordPress\AI\normalize_content( $content ); $this->assertStringNotContainsString( '
', $result, 'Should remove br tags' ); - $this->assertStringContainsString( "\n\n", $result, 'Should replace br with newlines' ); + $this->assertStringNotContainsString( "\n", $result, 'Should replace newlines with spaces' ); + $this->assertStringNotContainsString( "\r", $result, 'Should replace carriage returns with spaces' ); + $this->assertStringContainsString( 'Line 1', $result, 'Should preserve Line 1' ); + $this->assertStringContainsString( 'Line 2', $result, 'Should preserve Line 2' ); + $this->assertStringContainsString( 'Line 3', $result, 'Should preserve Line 3' ); + } + + /** + * Test that normalize_content() removes linebreaks and replaces with spaces. + * + * @since 0.1.0 + */ + public function test_normalize_content_removes_linebreaks() { + $content = "Line 1\nLine 2\rLine 3\r\nLine 4"; + $result = \WordPress\AI\normalize_content( $content ); + + $this->assertStringNotContainsString( "\n", $result, 'Should replace newlines with spaces' ); + $this->assertStringNotContainsString( "\r", $result, 'Should replace carriage returns with spaces' ); + $this->assertStringContainsString( 'Line 1', $result, 'Should preserve Line 1' ); + $this->assertStringContainsString( 'Line 2', $result, 'Should preserve Line 2' ); + $this->assertStringContainsString( 'Line 3', $result, 'Should preserve Line 3' ); + $this->assertStringContainsString( 'Line 4', $result, 'Should preserve Line 4' ); + // Verify lines are separated by spaces, not running together + $this->assertStringContainsString( 'Line 1 Line 2', $result, 'Lines should be separated by spaces' ); } /** @@ -356,7 +379,7 @@ public function test_get_preferred_image_models_returns_array() { public function test_get_preferred_image_models_returns_default_models() { $result = \WordPress\AI\get_preferred_image_models(); - $this->assertCount( 5, $result, 'Should have 5 preferred image models' ); + $this->assertCount( 6, $result, 'Should have 5 preferred image models' ); // Check first model (google). $this->assertIsArray( $result[0], 'First model should be an array' ); @@ -380,13 +403,19 @@ public function test_get_preferred_image_models_returns_default_models() { $this->assertIsArray( $result[3], 'Fourth model should be an array' ); $this->assertCount( 2, $result[3], 'Fourth model should have 2 elements' ); $this->assertEquals( 'openai', $result[3][0], 'Fourth model provider should be openai' ); - $this->assertEquals( 'gpt-image-1', $result[3][1], 'Fourth model name should be gpt-image-1' ); + $this->assertEquals( 'gpt-image-1.5', $result[3][1], 'Fourth model name should be gpt-image-1.5' ); // Check fifth model (openai). $this->assertIsArray( $result[4], 'Fifth model should be an array' ); $this->assertCount( 2, $result[4], 'Fifth model should have 2 elements' ); $this->assertEquals( 'openai', $result[4][0], 'Fifth model provider should be openai' ); - $this->assertEquals( 'dall-e-3', $result[4][1], 'Fifth model name should be dall-e-3' ); + $this->assertEquals( 'gpt-image-1', $result[4][1], 'Fifth model name should be gpt-image-1' ); + + // Check fifth model (openai). + $this->assertIsArray( $result[5], 'Fifth model should be an array' ); + $this->assertCount( 2, $result[5], 'Fifth model should have 2 elements' ); + $this->assertEquals( 'openai', $result[5][0], 'Fifth model provider should be openai' ); + $this->assertEquals( 'dall-e-3', $result[5][1], 'Fifth model name should be dall-e-3' ); } /** @@ -409,9 +438,9 @@ function( $models ) { $result = \WordPress\AI\get_preferred_image_models(); - $this->assertCount( 6, $result, 'Should have 6 models after filter' ); - $this->assertEquals( 'custom', $result[5][0], 'Sixth model provider should be custom' ); - $this->assertEquals( 'custom-image-model', $result[5][1], 'Sixth model name should be custom-image-model' ); + $this->assertCount( 7, $result, 'Should have 6 models after filter' ); + $this->assertEquals( 'custom', $result[6][0], 'Sixth model provider should be custom' ); + $this->assertEquals( 'custom-image-model', $result[6][1], 'Sixth model name should be custom-image-model' ); remove_all_filters( 'ai_experiments_preferred_image_models' ); } diff --git a/tests/e2e-request-mocking/e2e-request-mocking.php b/tests/e2e-request-mocking/e2e-request-mocking.php index bdb4a298..405ac30b 100644 --- a/tests/e2e-request-mocking/e2e-request-mocking.php +++ b/tests/e2e-request-mocking/e2e-request-mocking.php @@ -44,6 +44,11 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) { $response = file_get_contents( __DIR__ . '/responses/OpenAI/completions.json' ); } + // Mock the OpenAI images API response. + if ( str_contains( $url, 'https://api.openai.com/v1/images/generations' ) ) { + $response = file_get_contents( __DIR__ . '/responses/OpenAI/image.json' ); + } + if ( ! empty( $response ) ) { return array( 'headers' => array(), diff --git a/tests/e2e-request-mocking/responses/OpenAI/image.json b/tests/e2e-request-mocking/responses/OpenAI/image.json new file mode 100644 index 00000000..cc505661 --- /dev/null +++ b/tests/e2e-request-mocking/responses/OpenAI/image.json @@ -0,0 +1,8 @@ +{ + "created": 1589478378, + "data": [ + { + "b64_json": "iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAApgAAAKYB3X3/OAAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAANCSURBVEiJtZZPbBtFFMZ/M7ubXdtdb1xSFyeilBapySVU8h8OoFaooFSqiihIVIpQBKci6KEg9Q6H9kovIHoCIVQJJCKE1ENFjnAgcaSGC6rEnxBwA04Tx43t2FnvDAfjkNibxgHxnWb2e/u992bee7tCa00YFsffekFY+nUzFtjW0LrvjRXrCDIAaPLlW0nHL0SsZtVoaF98mLrx3pdhOqLtYPHChahZcYYO7KvPFxvRl5XPp1sN3adWiD1ZAqD6XYK1b/dvE5IWryTt2udLFedwc1+9kLp+vbbpoDh+6TklxBeAi9TL0taeWpdmZzQDry0AcO+jQ12RyohqqoYoo8RDwJrU+qXkjWtfi8Xxt58BdQuwQs9qC/afLwCw8tnQbqYAPsgxE1S6F3EAIXux2oQFKm0ihMsOF71dHYx+f3NND68ghCu1YIoePPQN1pGRABkJ6Bus96CutRZMydTl+TvuiRW1m3n0eDl0vRPcEysqdXn+jsQPsrHMquGeXEaY4Yk4wxWcY5V/9scqOMOVUFthatyTy8QyqwZ+kDURKoMWxNKr2EeqVKcTNOajqKoBgOE28U4tdQl5p5bwCw7BWquaZSzAPlwjlithJtp3pTImSqQRrb2Z8PHGigD4RZuNX6JYj6wj7O4TFLbCO/Mn/m8R+h6rYSUb3ekokRY6f/YukArN979jcW+V/S8g0eT/N3VN3kTqWbQ428m9/8k0P/1aIhF36PccEl6EhOcAUCrXKZXXWS3XKd2vc/TRBG9O5ELC17MmWubD2nKhUKZa26Ba2+D3P+4/MNCFwg59oWVeYhkzgN/JDR8deKBoD7Y+ljEjGZ0sosXVTvbc6RHirr2reNy1OXd6pJsQ+gqjk8VWFYmHrwBzW/n+uMPFiRwHB2I7ih8ciHFxIkd/3Omk5tCDV1t+2nNu5sxxpDFNx+huNhVT3/zMDz8usXC3ddaHBj1GHj/As08fwTS7Kt1HBTmyN29vdwAw+/wbwLVOJ3uAD1wi/dUH7Qei66PfyuRj4Ik9is+hglfbkbfR3cnZm7chlUWLdwmprtCohX4HUtlOcQjLYCu+fzGJH2QRKvP3UNz8bWk1qMxjGTOMThZ3kvgLI5AzFfo379UAAAAASUVORK5CYII=" + } + ] +} diff --git a/tests/e2e/specs/experiments/image-generation.spec.js b/tests/e2e/specs/experiments/image-generation.spec.js new file mode 100644 index 00000000..74f39b76 --- /dev/null +++ b/tests/e2e/specs/experiments/image-generation.spec.js @@ -0,0 +1,161 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + disableExperiment, + disableExperiments, + enableExperiment, + enableExperiments, + visitAdminPage, +} = require( '../../utils/helpers' ); + +test.describe( 'Image Generation Experiment', () => { + test( 'Can enable the image generation experiment', async ( { + admin, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Image Generation Experiment. + await enableExperiment( admin, page, 'image-generation' ); + } ); + + test( 'Can generate a Featured Image', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Image Generation Experiment. + await enableExperiment( admin, page, 'image-generation' ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Featured Image Generation Experiment', + content: + 'This is some test content for the Image Generation Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + // Ensure the generate featured image button exists. + await expect( + page.locator( + '.ai-featured-image .ai-featured-image__container button', + { + hasText: 'Generate featured image', + } + ) + ).toBeVisible(); + + // Click the generate featured image button. + await page + .locator( + '.ai-featured-image .ai-featured-image__container button' + ) + .click(); + + // Ensure the generated image is visible. + await expect( + page.locator( + '.editor-post-featured-image .editor-post-featured-image__container img' + ) + ).toBeVisible(); + + // Save the post. + await editor.saveDraft(); + + // Ensure the image is in the Media Library. + await visitAdminPage( admin, 'upload.php' ); + + const imageContainer = page + .locator( '.attachments-wrapper li' ) + .first(); + + await expect( imageContainer ).toHaveAttribute( + 'aria-label', + 'Edit or Delete Your First WordPress Post to Begin Your Blogging Adventure' + ); + + await expect( imageContainer.locator( 'img' ) ).toBeVisible(); + } ); + + test( 'Ensure the Image Generation Experiment UI is not visible when Experiments are globally disabled', async ( { + admin, + editor, + page, + } ) => { + // Enable the Image Generation Experiment. + await enableExperiment( admin, page, 'image-generation' ); + + // Globally turn off Experiments. + await disableExperiments( admin, page ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Image Generation Experiment Globally Disabled', + content: + 'This is some test content for the Image Generation Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + // Ensure the generate featured image button doesn't exist. + await expect( + page.locator( + '.ai-featured-image .ai-featured-image__container button' + ) + ).not.toBeVisible(); + } ); + + test( 'Ensure the Image Generation Experiment UI is not visible when the experiment is disabled', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Disable the Image Generation Experiment. + await disableExperiment( admin, page, 'image-generation' ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Image Generation Experiment Disabled', + content: + 'This is some test content for the Image Generation Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Ensure the sidebar is visible. + await editor.openDocumentSettingsSidebar(); + + // Ensure the generate featured image button doesn't exist. + await expect( + page.locator( + '.ai-featured-image .ai-featured-image__container button' + ) + ).not.toBeVisible(); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 6202f69d..e6327cb3 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -34,6 +34,11 @@ module.exports = { 'src/experiments/excerpt-generation', 'index.tsx' ), + 'experiments/image-generation': path.resolve( + process.cwd(), + 'src/experiments/image-generation', + 'index.ts' + ), 'experiments/summarization': path.resolve( process.cwd(), 'src/experiments/summarization',