diff --git a/features/generate.feature b/features/generate.feature index 8bbb1ae..235e9cf 100644 --- a/features/generate.feature +++ b/features/generate.feature @@ -58,7 +58,11 @@ Feature: Generate AI content new SupportedOption(OptionEnum::candidateCount()), new SupportedOption(OptionEnum::outputMimeType(), ['image/png']), new SupportedOption(OptionEnum::outputFileType(), [FileTypeEnum::inline()]), - new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::inputModalities(), [ + [ModalityEnum::text()], + [ModalityEnum::image()], + [ModalityEnum::text(), ModalityEnum::image()], + ]), new SupportedOption( OptionEnum::outputModalities(), [ @@ -274,3 +278,82 @@ Feature: Generate AI content """ data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII= """ + + @require-wp-7.0 + Scenario: Generates image with a local image file + Given a test-image.png file: + """ + fake-png-data + """ + + When I run `wp ai generate image "Edit this image" --image=test-image.png` + Then STDOUT should be: + """ + data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII= + """ + + @require-wp-7.0 + Scenario: Generates text with a local image file + Given a test-image.png file: + """ + fake-png-data + """ + + When I run `wp ai generate text "Describe this image" --image=test-image.png` + Then STDOUT should be: + """ + This is mock-generated text + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-existent local file + When I try `wp ai generate text "Describe this" --image=/tmp/nonexistent-image-12345.png` + Then the return code should be 1 + And STDERR should contain: + """ + Image file not found + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-existent attachment + When I try `wp ai generate text "Describe this" --image=99999` + Then the return code should be 1 + And STDERR should contain: + """ + Attachment with ID 99999 not found + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-image local file + Given a test-document.txt file: + """ + hello world + """ + + When I try `wp ai generate text "Describe this" --image=test-document.txt` + Then the return code should be 1 + And STDERR should contain: + """ + File does not appear to be an image + """ + + @require-wp-7.0 + Scenario: Generate text with image input fails for non-attachment post + When I run `wp post create --post_title="Test Post" --post_type=post --post_status=publish --porcelain` + Then save STDOUT as {POST_ID} + + When I try `wp ai generate text "Describe this" --image={POST_ID}` + Then the return code should be 1 + And STDERR should contain: + """ + is not an attachment + """ + + @require-wp-7.0 + Scenario: Generate text with image input treats 0 as a file path + When I try `wp ai generate text "Describe this image" --image=0` + Then the return code should be 1 + And STDERR should contain: + """ + Image file not found: 0 + """ diff --git a/src/AI_Command.php b/src/AI_Command.php index dca65a9..dbb1a7c 100644 --- a/src/AI_Command.php +++ b/src/AI_Command.php @@ -77,6 +77,9 @@ class AI_Command extends WP_CLI_Command { * [--system-instruction=] * : System instruction to guide the AI's behavior. * + * [--image=] + * : An image to use as input for text or image generation. Can be a local file path, a URL, a data URI, or a WordPress attachment ID. + * * [--destination-file=] * : For image generation, path to save the generated image. * @@ -109,11 +112,20 @@ class AI_Command extends WP_CLI_Command { * # Generate with system instruction * $ wp ai generate text "Explain AI" --system-instruction="Explain as if to a 5-year-old" * + * # Generate text from a prompt with an image + * $ wp ai generate text "Describe this image" --image=photo.jpg + * + * # Generate alt text for an attachment + * $ wp ai generate text "Generate alt text for this image" --image=42 + * + * # Generate text with an image URL + * $ wp ai generate text "What is in this image?" --image=https://example.com/photo.jpg + * * # Generate image * $ wp ai generate image "A minimalist WordPress logo" --destination-file=wp-logo.png * * @param array{0: string, 1: string} $args Positional arguments. - * @param array{model: string, provider: string, temperature: float, 'top-p': float, 'top-k': int, 'max-tokens': int, 'system-instruction': string, 'destination-file': string, stdout: bool, format: string} $assoc_args Associative arguments. + * @param array{model: string, provider: string, temperature: float, 'top-p': float, 'top-k': int, 'max-tokens': int, 'system-instruction': string, image?: string, 'destination-file': string, stdout: bool, format: string} $assoc_args Associative arguments. * @return void */ public function generate( $args, $assoc_args ) { @@ -186,8 +198,14 @@ public function generate( $args, $assoc_args ) { } if ( 'text' === $type ) { + if ( isset( $assoc_args['image'] ) ) { + $builder = $this->with_image_input( $builder, $assoc_args['image'] ); + } $this->generate_text( $builder, $assoc_args ); } elseif ( 'image' === $type ) { + if ( isset( $assoc_args['image'] ) ) { + $builder = $this->with_image_input( $builder, $assoc_args['image'] ); + } $this->generate_image( $builder, $assoc_args ); } } catch ( \Exception $e ) { @@ -504,4 +522,123 @@ private function generate_image( $builder, $assoc_args ) { WP_CLI::line( (string) $image_file->getDataUri() ); } } + + /** + * Adds image input to the prompt builder. + * + * Accepts a local file path, a URL (http/https), a data URI, or a WordPress attachment ID. + * + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param string $image_input Local file path, URL, data URI, or attachment ID. + * @return mixed The updated prompt builder. + * + * @phpstan-ignore class.notFound + */ + private function with_image_input( $builder, $image_input ) { + // Attachment ID (positive integer string). + if ( ctype_digit( $image_input ) && (int) $image_input > 0 ) { + return $this->with_attachment_input( $builder, (int) $image_input ); + } + + // Data URI or remote URL — pass directly to with_file(). + if ( + 0 === strpos( $image_input, 'data:' ) || + 0 === strpos( $image_input, 'http://' ) || + 0 === strpos( $image_input, 'https://' ) + ) { + // @phpstan-ignore class.notFound + return $builder->with_file( $image_input ); + } + + // Local file path. + return $this->with_local_file_input( $builder, $image_input ); + } + + /** + * Adds a WordPress attachment as image input to the prompt builder. + * + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param int $attachment_id The attachment ID. + * @return mixed The updated prompt builder. + * + * @phpstan-ignore class.notFound + */ + private function with_attachment_input( $builder, $attachment_id ) { + $attachment = get_post( $attachment_id ); + + if ( ! $attachment ) { + WP_CLI::error( "Attachment with ID {$attachment_id} not found." ); + } + + if ( 'attachment' !== $attachment->post_type ) { + WP_CLI::error( "Post with ID {$attachment_id} is not an attachment." ); + } + + if ( ! wp_attachment_is_image( $attachment_id ) ) { + WP_CLI::error( "Attachment {$attachment_id} is not an image." ); + } + + $file_path = get_attached_file( $attachment_id ); + + if ( $file_path && file_exists( $file_path ) ) { + $mime_type = get_post_mime_type( $attachment_id ); + if ( ! $mime_type ) { + WP_CLI::error( "Could not determine MIME type for attachment {$attachment_id}." ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $file_path ); + if ( false === $contents ) { + WP_CLI::error( "Could not read image file for attachment {$attachment_id}." ); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $data_uri = 'data:' . $mime_type . ';base64,' . base64_encode( $contents ); + + // @phpstan-ignore class.notFound + return $builder->with_file( $data_uri ); + } + + // Fall back to the attachment URL. + $image_src = wp_get_attachment_image_src( $attachment_id, 'full' ); + if ( $image_src && ! empty( $image_src[0] ) ) { + // @phpstan-ignore class.notFound + return $builder->with_file( $image_src[0] ); + } + + WP_CLI::error( "Could not retrieve image for attachment {$attachment_id}." ); + } + + /** + * Adds a local image file as input to the prompt builder. + * + * @param \WordPress\AI_Client\Builders\Prompt_Builder $builder The prompt builder. + * @param string $file_path The local file path. + * @return mixed The updated prompt builder. + * + * @phpstan-ignore class.notFound + */ + private function with_local_file_input( $builder, $file_path ) { + if ( ! file_exists( $file_path ) ) { + WP_CLI::error( "Image file not found: {$file_path}" ); + } + + $filetype = wp_check_filetype( $file_path ); + $mime_type = $filetype['type']; + if ( ! $mime_type || 0 !== strpos( $mime_type, 'image/' ) ) { + WP_CLI::error( "File does not appear to be an image: {$file_path}" ); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $contents = file_get_contents( $file_path ); + if ( false === $contents ) { + WP_CLI::error( "Could not read image file: {$file_path}" ); + } + + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $data_uri = 'data:' . $mime_type . ';base64,' . base64_encode( $contents ); + + // @phpstan-ignore class.notFound + return $builder->with_file( $data_uri ); + } }