From 6191a6c06c46d13d91f1465da6aaec231e57d582 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 5 Dec 2025 09:45:47 -0700 Subject: [PATCH 01/54] Enqueue the admin script --- .../Image_Generation/Image_Generation.php | 35 +++++++++++++++++++ webpack.config.js | 5 +++ 2 files changed, 40 insertions(+) diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index a4a4d164..e7cdad5f 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -12,6 +12,7 @@ use WordPress\AI\Abilities\Image\Generate as Image_Generation_Ability; use WordPress\AI\Abilities\Image\Import as Image_Import_Ability; use WordPress\AI\Abstracts\Abstract_Experiment; +use WordPress\AI\Asset_Loader; /** * Image generation experiment. @@ -42,6 +43,7 @@ protected function load_experiment_metadata(): array { */ public function register(): void { add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } /** @@ -68,4 +70,37 @@ public function register_abilities(): void { ), ); } + + /** + * 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(), + ) + ); + } } diff --git a/webpack.config.js b/webpack.config.js index 49a296e9..380a3d8d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -24,6 +24,11 @@ module.exports = { 'src/experiments/example-experiment', 'index.tsx' ), + 'experiments/image-generation': path.resolve( + process.cwd(), + 'src/experiments/image-generation', + 'index.tsx' + ), 'experiments/title-generation': path.resolve( process.cwd(), 'src/experiments/title-generation', From 3ea858bbf9aa730e00c5c098afb8df12b1041f19 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 5 Dec 2025 09:46:17 -0700 Subject: [PATCH 02/54] Add the initial filtering of the featured image component --- .../image-generation/featured-image.tsx | 46 +++++++++++++++++++ src/experiments/image-generation/index.tsx | 8 ++++ 2 files changed, 54 insertions(+) create mode 100644 src/experiments/image-generation/featured-image.tsx create mode 100644 src/experiments/image-generation/index.tsx diff --git a/src/experiments/image-generation/featured-image.tsx b/src/experiments/image-generation/featured-image.tsx new file mode 100644 index 00000000..ce40b363 --- /dev/null +++ b/src/experiments/image-generation/featured-image.tsx @@ -0,0 +1,46 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { createElement } from '@wordpress/element'; +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import GenerateFeaturedImage from './components/generate-featured-image'; + +const { aiImageGenerationData } = window as any; + +/** + * Wraps the PostFeaturedImage component to add a generate featured image button. + * + * @param {React.ComponentType} OriginalComponent - The original PostFeaturedImage component. + * @return {React.ComponentType} 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/index.tsx b/src/experiments/image-generation/index.tsx new file mode 100644 index 00000000..6f287181 --- /dev/null +++ b/src/experiments/image-generation/index.tsx @@ -0,0 +1,8 @@ +/** + * Image generation experiment. + */ + +/** + * Internal dependencies + */ +import './featured-image'; From eb1a9f638a7676d4b68629e9b6ee83dc34dceecb Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 5 Dec 2025 10:47:26 -0700 Subject: [PATCH 03/54] Add the Generate Featured Image component --- .../components/generate-featured-image.tsx | 97 +++++++++++++++++++ .../image-generation/featured-image.tsx | 4 +- 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/experiments/image-generation/components/generate-featured-image.tsx diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/generate-featured-image.tsx new file mode 100644 index 00000000..672eff5f --- /dev/null +++ b/src/experiments/image-generation/components/generate-featured-image.tsx @@ -0,0 +1,97 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { executeAbility } from '@wordpress/abilities'; +import { Button } from '@wordpress/components'; +import { dispatch, select } 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'; + +/** + * Generates an image for the given post ID and content. + * + * @param {string} content The content of the post to generate an image for. + * @return {Promise} A promise that resolves to the generated image. + */ +async function generateImage( content: string ): Promise< string > { + return executeAbility( 'ai/image-generation', { + prompt: content, + } ) + .then( ( response ) => { + if ( response && typeof response === 'string' ) { + return response; + } + + return ''; + } ) + .catch( ( error ) => { + throw new Error( `Error generating titles: ${ error.message }` ); + } ); +} + +/** + * GenerateFeaturedImage component. + * + * Provides a button to generate a featured image. + * + * @return {JSX.Element} The GenerateFeaturedImage component. + */ +export default function GenerateFeaturedImage(): JSX.Element { + const content = select( editorStore ).getEditedPostContent(); + + const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); + const [ image, setImage ] = useState< string >( '' ); + + /** + * Handles the generate/re-generate button click. + */ + const handleGenerate = async () => { + setIsGenerating( true ); + ( dispatch( noticesStore ) as any ).removeNotice( + 'ai_image_generation_error' + ); + + try { + const generatedImage = await generateImage( content ); + setImage( generatedImage ); + } catch ( error: any ) { + ( dispatch( noticesStore ) as any ).createErrorNotice( error, { + id: 'ai_image_generation_error', + isDismissible: true, + } ); + setImage( '' ); + } finally { + setIsGenerating( false ); + } + }; + + return ( +
+
+ { image && ( + { + ) } + +
+
+ ); +} diff --git a/src/experiments/image-generation/featured-image.tsx b/src/experiments/image-generation/featured-image.tsx index ce40b363..dc4009a0 100644 --- a/src/experiments/image-generation/featured-image.tsx +++ b/src/experiments/image-generation/featured-image.tsx @@ -19,8 +19,8 @@ const { aiImageGenerationData } = window as any; /** * Wraps the PostFeaturedImage component to add a generate featured image button. * - * @param {React.ComponentType} OriginalComponent - The original PostFeaturedImage component. - * @return {React.ComponentType} The wrapped component. + * @param OriginalComponent - The original PostFeaturedImage component. + * @return The wrapped component. */ function wrapPostFeaturedImage( OriginalComponent: React.ComponentType< any > From 700b451e498f470d67f596600c3a41882f9cd27d Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 5 Dec 2025 13:30:41 -0700 Subject: [PATCH 04/54] Use apiFetch instead of the Abilities API as we're likely to remove that as a dependency --- .../Image_Generation/Image_Generation.php | 1 + package-lock.json | 90 +++++-------------- package.json | 1 + .../components/generate-featured-image.tsx | 16 +++- 4 files changed, 37 insertions(+), 71 deletions(-) diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index e7cdad5f..f3f1040b 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -100,6 +100,7 @@ public function enqueue_assets( string $hook_suffix ): void { 'ImageGenerationData', array( 'enabled' => $this->is_enabled(), + 'path' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', ) ); } diff --git a/package-lock.json b/package-lock.json index 27b8a4fc..4791d10b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.1", "license": "GPL-2.0-or-later", "dependencies": { + "@wordpress/api-fetch": "^7.36.0", "@wordpress/block-editor": "^15.7.0", "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", @@ -110,7 +111,6 @@ "version": "7.28.5", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1865,7 +1865,6 @@ "integrity": "sha512-eohl3hKTiVyD1ilYdw9T0OiB4hnjef89e3dMYKz+mVKDzj+5IteTseASUsOB+EU9Tf6VNTCjDePcP6wkDGmLKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -1907,7 +1906,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1931,7 +1929,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2105,7 +2102,6 @@ "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz", "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -3457,7 +3453,6 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -3481,7 +3476,6 @@ "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" }, @@ -3495,7 +3489,6 @@ "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, @@ -3978,7 +3971,6 @@ "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" @@ -4006,7 +3998,6 @@ "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", @@ -4035,7 +4026,6 @@ "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "dev": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -5380,7 +5370,6 @@ "version": "8.1.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5727,7 +5716,6 @@ "version": "9.6.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5953,7 +5941,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5964,7 +5951,6 @@ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^18.0.0" } @@ -6575,7 +6561,6 @@ "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.9.2" } @@ -6680,7 +6665,6 @@ "version": "6.21.0", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.21.0", "@typescript-eslint/types": "6.21.0", @@ -7353,13 +7337,13 @@ } }, "node_modules/@wordpress/api-fetch": { - "version": "7.35.0", - "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.35.0.tgz", - "integrity": "sha512-jWIhNUVYkUOsVLvGodBhzLe4DR+gCNmh7sm91Vce/79M+DXMtEFLc8jHJ5kXKudnPPTzy29KF1ZzZJ9rm9sqRQ==", + "version": "7.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/api-fetch/-/api-fetch-7.36.0.tgz", + "integrity": "sha512-71yTZi1tSqYbfzT5O+Cx2L2gWpp3y+twdch8mGIzpRmNDz6L/NvntIko7Qmc73tu3dSVC7KakvEmCduOaDNKRQ==", "license": "GPL-2.0-or-later", "dependencies": { - "@wordpress/i18n": "^6.8.0", - "@wordpress/url": "^4.35.0" + "@wordpress/i18n": "^6.9.0", + "@wordpress/url": "^4.36.0" }, "engines": { "node": ">=18.12.0", @@ -8049,7 +8033,6 @@ "version": "10.34.0", "dev": true, "license": "GPL-2.0-or-later", - "peer": true, "dependencies": { "@inquirer/prompts": "^7.2.0", "chalk": "^4.0.0", @@ -8227,9 +8210,9 @@ } }, "node_modules/@wordpress/hooks": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.35.0.tgz", - "integrity": "sha512-PQcAv/zfMYn5sPScOWDu1vgYkyHaDFt7+1IHvwR0RGE0AdQrdnKjvm6VJ4ALugA+zvJZkBZxLk5Gm+NZGAWIMg==", + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/hooks/-/hooks-4.36.0.tgz", + "integrity": "sha512-9kB2lanmVrubJEqWDSHtyUx7q4ZAWGArakY/GsUdlFsnf9m+VmQLQl92uCpHWYjKzHec1hwcBhBB3Tu9aBWDtQ==", "license": "GPL-2.0-or-later", "engines": { "node": ">=18.12.0", @@ -8247,13 +8230,13 @@ } }, "node_modules/@wordpress/i18n": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.8.0.tgz", - "integrity": "sha512-lNMjf0VXWm2qzkEI+v1psvb26FN+B32sO6nLOFxs5Lay/E0WVqCw31DiRw8nETlzVwsMheO+iy0lCSOLvg+jog==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/@wordpress/i18n/-/i18n-6.9.0.tgz", + "integrity": "sha512-ke4BPQUHmj82mwYoasotKt3Sghf0jK4vec56cWxwnzUvqq7LMy/0H7F5NzJ4CY378WS+TOdLbqmIb4sj+f7eog==", "license": "GPL-2.0-or-later", "dependencies": { "@tannin/sprintf": "^1.3.2", - "@wordpress/hooks": "^4.35.0", + "@wordpress/hooks": "^4.36.0", "gettext-parser": "^1.3.1", "memize": "^2.1.0", "tannin": "^1.2.0" @@ -8971,9 +8954,9 @@ } }, "node_modules/@wordpress/url": { - "version": "4.35.0", - "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.35.0.tgz", - "integrity": "sha512-h6KK0OePnc64T50BwVWmQb7xmcMW0XDtfh+1m/a0tlzXGLU4TnBjUUs+a0h0y6Tdla14lc2BtbLJLFwky06vbg==", + "version": "4.36.0", + "resolved": "https://registry.npmjs.org/@wordpress/url/-/url-4.36.0.tgz", + "integrity": "sha512-b61pCnJCjaxIiiH/+leR3IVZlKUlSP/PnYCFg1cLa9Qv8TQBr5REnmtBDnrfNzaHEP7uE+A81BJe5lVFP/AQgw==", "license": "GPL-2.0-or-later", "dependencies": { "remove-accents": "^0.5.0" @@ -9060,7 +9043,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -9140,7 +9122,6 @@ "version": "6.12.6", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -10054,7 +10035,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -11690,8 +11670,7 @@ "node_modules/devtools-protocol": { "version": "0.0.1507524", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "4.0.2", @@ -12289,7 +12268,6 @@ "version": "8.57.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -12344,7 +12322,6 @@ "version": "8.10.2", "dev": true, "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -12457,7 +12434,6 @@ "version": "2.32.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -15559,7 +15535,6 @@ "version": "29.7.0", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -16560,8 +16535,7 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1521046.tgz", "integrity": "sha512-vhE6eymDQSKWUXwwA37NtTTVEzjtGVfDr3pRbsWEQ5onH/Snp2c+2xZHWJJawG/0hCCJLRGt4xVtEVUVILol4w==", "dev": true, - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/lighthouse/node_modules/puppeteer-core/node_modules/ws": { "version": "8.18.3", @@ -17598,7 +17572,6 @@ "integrity": "sha512-cuXAJJB1Rdqz0UO6w524matlBqDBjcNt7Ru+RDIu4y6RI1gVqiWBnylrK8sPRk81gGBA0X8hJbDXolVOoTc+sA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "^6.12.6", "ajv-errors": "^1.0.1", @@ -18561,6 +18534,7 @@ "integrity": "sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.56.1" }, @@ -18580,6 +18554,7 @@ "integrity": "sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -18598,6 +18573,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -18643,7 +18619,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19315,7 +19290,6 @@ "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", "integrity": "sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==", "license": "MIT", - "peer": true, "funding": { "type": "opencollective", "url": "https://opencollective.com/preact" @@ -19335,7 +19309,6 @@ "resolved": "https://registry.npmjs.org/wp-prettier/-/wp-prettier-3.0.3.tgz", "integrity": "sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==", "dev": true, - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -19680,7 +19653,6 @@ "node_modules/react": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -19747,7 +19719,6 @@ "node_modules/react-dom": { "version": "18.3.1", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -19778,7 +19749,6 @@ "version": "0.14.2", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -20001,8 +19971,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -20504,7 +20473,6 @@ "version": "1.93.3", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -20599,7 +20567,6 @@ "version": "8.17.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21963,7 +21930,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4", @@ -22299,7 +22265,6 @@ "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -22820,7 +22785,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -22997,8 +22961,7 @@ }, "node_modules/tslib": { "version": "2.8.1", - "license": "0BSD", - "peer": true + "license": "0BSD" }, "node_modules/tsutils": { "version": "3.21.0", @@ -23042,7 +23005,6 @@ "version": "0.20.2", "dev": true, "license": "(MIT OR CC0-1.0)", - "peer": true, "engines": { "node": ">=10" }, @@ -23148,7 +23110,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23251,7 +23212,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -23622,7 +23582,6 @@ "version": "5.102.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -23723,7 +23682,6 @@ "version": "5.1.4", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -23823,7 +23781,6 @@ "version": "5.2.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -24484,7 +24441,6 @@ "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.27.tgz", "integrity": "sha512-OIDwaflOaq4wC6YlPBy2L6ceKeKuF7DeTxx+jPzv1FHn9tCZ0ZwSRnUBxD05E3yed46fv/FWJbvR+Ud7x0L7zw==", "license": "MIT", - "peer": true, "dependencies": { "lib0": "^0.2.99" }, diff --git a/package.json b/package.json index 1ad32c20..edc22f78 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "webpack-remove-empty-scripts": "^1.1.1" }, "dependencies": { + "@wordpress/api-fetch": "^7.36.0", "@wordpress/block-editor": "^15.7.0", "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/generate-featured-image.tsx index 672eff5f..26548f80 100644 --- a/src/experiments/image-generation/components/generate-featured-image.tsx +++ b/src/experiments/image-generation/components/generate-featured-image.tsx @@ -6,7 +6,7 @@ import React from 'react'; /** * WordPress dependencies */ -import { executeAbility } from '@wordpress/abilities'; +import apiFetch from '@wordpress/api-fetch'; import { Button } from '@wordpress/components'; import { dispatch, select } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; @@ -14,6 +14,8 @@ import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; +const { aiImageGenerationData } = window as any; + /** * Generates an image for the given post ID and content. * @@ -21,8 +23,14 @@ import { store as noticesStore } from '@wordpress/notices'; * @return {Promise} A promise that resolves to the generated image. */ async function generateImage( content: string ): Promise< string > { - return executeAbility( 'ai/image-generation', { - prompt: content, + return apiFetch( { + path: aiImageGenerationData?.path ?? '', + method: 'POST', + data: { + input: { + prompt: content, + }, + }, } ) .then( ( response ) => { if ( response && typeof response === 'string' ) { @@ -32,7 +40,7 @@ async function generateImage( content: string ): Promise< string > { return ''; } ) .catch( ( error ) => { - throw new Error( `Error generating titles: ${ error.message }` ); + throw new Error( error.message ); } ); } From b2cc9c12de0cb58e2aa8c93a81530575db9e04cd Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 5 Dec 2025 14:05:55 -0700 Subject: [PATCH 05/54] Continue work on the generate component, matching what core does more closely to render the image and set/remove buttons --- .../components/generate-featured-image.tsx | 62 +++++++++++++------ 1 file changed, 44 insertions(+), 18 deletions(-) diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/generate-featured-image.tsx index 26548f80..444ef61f 100644 --- a/src/experiments/image-generation/components/generate-featured-image.tsx +++ b/src/experiments/image-generation/components/generate-featured-image.tsx @@ -7,7 +7,7 @@ import React from 'react'; * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; -import { Button } from '@wordpress/components'; +import { Button, __experimentalHStack as HStack } from '@wordpress/components'; import { dispatch, select } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { useState } from '@wordpress/element'; @@ -58,7 +58,7 @@ export default function GenerateFeaturedImage(): JSX.Element { const [ image, setImage ] = useState< string >( '' ); /** - * Handles the generate/re-generate button click. + * Handles the generate button click. */ const handleGenerate = async () => { setIsGenerating( true ); @@ -81,24 +81,50 @@ export default function GenerateFeaturedImage(): JSX.Element { }; return ( -
-
+
+
{ image && ( - { +
+ { +
+ ) } + { ! image && ( + + ) } + { !! image && ( + + + + ) } -
); From 4ae3f31426efa35b24fd7d13b989bc5dfea2d39e Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 5 Dec 2025 15:51:53 -0700 Subject: [PATCH 06/54] Add function to upload an image after it is generated so we have something to use across loads --- .../Image_Generation/Image_Generation.php | 5 +- .../components/generate-featured-image.tsx | 59 ++++++++++++++++++- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index f3f1040b..fa7e71c6 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -99,8 +99,9 @@ public function enqueue_assets( string $hook_suffix ): void { 'image_generation', 'ImageGenerationData', array( - 'enabled' => $this->is_enabled(), - 'path' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', + 'enabled' => $this->is_enabled(), + 'generatePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', + 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', ) ); } diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/generate-featured-image.tsx index 444ef61f..0a670505 100644 --- a/src/experiments/image-generation/components/generate-featured-image.tsx +++ b/src/experiments/image-generation/components/generate-featured-image.tsx @@ -16,6 +16,17 @@ import { store as noticesStore } from '@wordpress/notices'; const { aiImageGenerationData } = window as any; +/** + * TODO: + * - Save post meta with the generated image ID and update our code to use that + * - Add ability to see full image in a modal or lightbox (or link to media library view MediaUpload component) + * - Wire up the set button (or think about auto-setting as featured image when generated) + * - Wire up the remove button + * - Add regenerate button and wire it up + * - Add middleware ability to take post context and generate prompt we can pass to image gen + * - Styling to make generated image appear separate from featured image + */ + /** * Generates an image for the given post ID and content. * @@ -24,7 +35,7 @@ const { aiImageGenerationData } = window as any; */ async function generateImage( content: string ): Promise< string > { return apiFetch( { - path: aiImageGenerationData?.path ?? '', + path: aiImageGenerationData?.generatePath ?? '', method: 'POST', data: { input: { @@ -44,6 +55,47 @@ async function generateImage( content: string ): Promise< string > { } ); } +/** + * Uploads an image to the media library. + * + * @param {string} image The image to upload. + * @return {Promise<{ id: number; url: string; title: string }>} A promise that resolves to the uploaded image data. + */ +async function uploadImage( image: string ): Promise< { + id: number; + url: string; + title: string; +} > { + return apiFetch( { + path: aiImageGenerationData?.importPath ?? '', + method: 'POST', + data: { + input: { + data: image, + mime_type: 'image/png', + }, + }, + } ) + .then( ( response: any ) => { + if ( + response && + typeof response === 'object' && + 'image' in response + ) { + return response.image as { + id: number; + url: string; + title: string; + }; + } + + throw new Error( 'Invalid response from image import' ); + } ) + .catch( ( error ) => { + throw new Error( error.message ); + } ); +} + /** * GenerateFeaturedImage component. * @@ -68,7 +120,8 @@ export default function GenerateFeaturedImage(): JSX.Element { try { const generatedImage = await generateImage( content ); - setImage( generatedImage ); + const importedImage = await uploadImage( generatedImage ); + setImage( importedImage.url ); } catch ( error: any ) { ( dispatch( noticesStore ) as any ).createErrorNotice( error, { id: 'ai_image_generation_error', @@ -86,7 +139,7 @@ export default function GenerateFeaturedImage(): JSX.Element { { image && (
{ From 7d0af2e1b149d0eeba39356e0815330076f67ac0 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 8 Dec 2025 16:20:45 -0700 Subject: [PATCH 07/54] After the image is generated, store that as post meta and use that when the component loads --- .../Image_Generation/Image_Generation.php | 18 ++++++++ .../components/generate-featured-image.tsx | 44 +++++++++++++++++-- 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index fa7e71c6..c666afcd 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -42,10 +42,28 @@ protected function load_experiment_metadata(): array { * @since x.x.x */ 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( + 'post', + 'ai_featured_image', + array( + 'type' => 'integer', + 'single' => true, + 'show_in_rest' => true, + ) + ); + } + /** * Registers any needed abilities. * diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/generate-featured-image.tsx index 0a670505..4fd805f8 100644 --- a/src/experiments/image-generation/components/generate-featured-image.tsx +++ b/src/experiments/image-generation/components/generate-featured-image.tsx @@ -8,9 +8,10 @@ import React from 'react'; */ import apiFetch from '@wordpress/api-fetch'; import { Button, __experimentalHStack as HStack } from '@wordpress/components'; -import { dispatch, select } from '@wordpress/data'; +import { store as coreStore } from '@wordpress/core-data'; +import { dispatch, select, useDispatch, useSelect } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; -import { useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; @@ -18,7 +19,6 @@ const { aiImageGenerationData } = window as any; /** * TODO: - * - Save post meta with the generated image ID and update our code to use that * - Add ability to see full image in a modal or lightbox (or link to media library view MediaUpload component) * - Wire up the set button (or think about auto-setting as featured image when generated) * - Wire up the remove button @@ -104,11 +104,42 @@ async function uploadImage( image: string ): Promise< { * @return {JSX.Element} The GenerateFeaturedImage component. */ export default function GenerateFeaturedImage(): JSX.Element { + const { editPost } = useDispatch( editorStore ); + const { saveEditedEntityRecord } = useDispatch( coreStore ); + const content = select( editorStore ).getEditedPostContent(); + const meta = select( editorStore ).getEditedPostAttribute( 'meta' ); + const postId = select( editorStore ).getCurrentPostId(); + const postType = select( editorStore ).getCurrentPostType(); + const currentAiImageId = meta?.ai_featured_image; + + // See if we have an existing image to display. + const aiImage = useSelect( + ( selectStore ) => { + if ( ! currentAiImageId ) { + return null; + } + return selectStore( coreStore ).getEntityRecord( + 'postType', + 'attachment', + currentAiImageId + ); + }, + [ currentAiImageId ] + ); const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); const [ image, setImage ] = useState< string >( '' ); + // Sync image state when entity record becomes available. + useEffect( () => { + if ( aiImage?.source_url ) { + setImage( aiImage.source_url ); + } else if ( ! currentAiImageId ) { + setImage( '' ); + } + }, [ aiImage, currentAiImageId ] ); + /** * Handles the generate button click. */ @@ -121,6 +152,13 @@ export default function GenerateFeaturedImage(): JSX.Element { try { const generatedImage = await generateImage( content ); const importedImage = await uploadImage( generatedImage ); + editPost( { + meta: { + ...meta, + ai_featured_image: importedImage.id, + }, + } ); + saveEditedEntityRecord( 'postType', postType, postId ); setImage( importedImage.url ); } catch ( error: any ) { ( dispatch( noticesStore ) as any ).createErrorNotice( error, { From 58e7c1dfa441acc7e6109d7f24c51ac8d623762c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 8 Dec 2025 16:45:45 -0700 Subject: [PATCH 08/54] Remove image when the remove button is clicked --- .../components/generate-featured-image.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/generate-featured-image.tsx index 4fd805f8..366dee45 100644 --- a/src/experiments/image-generation/components/generate-featured-image.tsx +++ b/src/experiments/image-generation/components/generate-featured-image.tsx @@ -21,7 +21,6 @@ const { aiImageGenerationData } = window as any; * TODO: * - Add ability to see full image in a modal or lightbox (or link to media library view MediaUpload component) * - Wire up the set button (or think about auto-setting as featured image when generated) - * - Wire up the remove button * - Add regenerate button and wire it up * - Add middleware ability to take post context and generate prompt we can pass to image gen * - Styling to make generated image appear separate from featured image @@ -209,7 +208,18 @@ export default function GenerateFeaturedImage(): JSX.Element { __next40pxDefaultSize className="editor-post-featured-image__action" onClick={ () => { - console.log( 'remove image' ); + editPost( { + meta: { + ...meta, + ai_featured_image: null, + }, + } ); + saveEditedEntityRecord( + 'postType', + postType, + postId + ); + setImage( '' ); } } > { __( 'Remove', 'ai' ) } From c19454f7aa5bf2cb0722d6c916604bd34f726a13 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 9 Dec 2025 10:34:05 -0700 Subject: [PATCH 09/54] Move some functions to their own files --- .../components/generate-featured-image.tsx | 77 ++----------------- .../functions/generate-image.tsx | 34 ++++++++ .../functions/upload-image.tsx | 47 +++++++++++ 3 files changed, 87 insertions(+), 71 deletions(-) create mode 100644 src/experiments/image-generation/functions/generate-image.tsx create mode 100644 src/experiments/image-generation/functions/upload-image.tsx diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/generate-featured-image.tsx index 366dee45..a587cab8 100644 --- a/src/experiments/image-generation/components/generate-featured-image.tsx +++ b/src/experiments/image-generation/components/generate-featured-image.tsx @@ -6,7 +6,6 @@ import React from 'react'; /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; import { Button, __experimentalHStack as HStack } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; import { dispatch, select, useDispatch, useSelect } from '@wordpress/data'; @@ -15,7 +14,11 @@ import { useEffect, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; -const { aiImageGenerationData } = window as any; +/** + * Internal dependencies + */ +import { generateImage } from '../functions/generate-image'; +import { uploadImage } from '../functions/upload-image'; /** * TODO: @@ -24,77 +27,9 @@ const { aiImageGenerationData } = window as any; * - Add regenerate button and wire it up * - Add middleware ability to take post context and generate prompt we can pass to image gen * - Styling to make generated image appear separate from featured image + * - Look at creating functions for setting and removing the image. */ -/** - * Generates an image for the given post ID and content. - * - * @param {string} content The content of the post to generate an image for. - * @return {Promise} A promise that resolves to the generated image. - */ -async function generateImage( content: string ): Promise< string > { - return apiFetch( { - path: aiImageGenerationData?.generatePath ?? '', - method: 'POST', - data: { - input: { - prompt: content, - }, - }, - } ) - .then( ( response ) => { - if ( response && typeof response === 'string' ) { - return response; - } - - return ''; - } ) - .catch( ( error ) => { - throw new Error( error.message ); - } ); -} - -/** - * Uploads an image to the media library. - * - * @param {string} image The image to upload. - * @return {Promise<{ id: number; url: string; title: string }>} A promise that resolves to the uploaded image data. - */ -async function uploadImage( image: string ): Promise< { - id: number; - url: string; - title: string; -} > { - return apiFetch( { - path: aiImageGenerationData?.importPath ?? '', - method: 'POST', - data: { - input: { - data: image, - mime_type: 'image/png', - }, - }, - } ) - .then( ( response: any ) => { - if ( - response && - typeof response === 'object' && - 'image' in response - ) { - return response.image as { - id: number; - url: string; - title: string; - }; - } - - throw new Error( 'Invalid response from image import' ); - } ) - .catch( ( error ) => { - throw new Error( error.message ); - } ); -} - /** * GenerateFeaturedImage component. * diff --git a/src/experiments/image-generation/functions/generate-image.tsx b/src/experiments/image-generation/functions/generate-image.tsx new file mode 100644 index 00000000..0b4fc0e6 --- /dev/null +++ b/src/experiments/image-generation/functions/generate-image.tsx @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +const { aiImageGenerationData } = window as any; + +/** + * Generates an image for the given content. + * + * @param {string} content The content of the post to generate an image for. + * @return {Promise} A promise that resolves to the generated image. + */ +export async function generateImage( content: string ): Promise< string > { + return apiFetch( { + path: aiImageGenerationData?.generatePath ?? '', + method: 'POST', + data: { + input: { + prompt: content, + }, + }, + } ) + .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/upload-image.tsx b/src/experiments/image-generation/functions/upload-image.tsx new file mode 100644 index 00000000..64c207ee --- /dev/null +++ b/src/experiments/image-generation/functions/upload-image.tsx @@ -0,0 +1,47 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +const { aiImageGenerationData } = window as any; + +/** + * Uploads an image to the media library. + * + * @param {string} image The image to upload. + * @return {Promise<{ id: number; url: string; title: string }>} A promise that resolves to the uploaded image data. + */ +export async function uploadImage( image: string ): Promise< { + id: number; + url: string; + title: string; +} > { + return apiFetch( { + path: aiImageGenerationData?.importPath ?? '', + method: 'POST', + data: { + input: { + data: image, + mime_type: 'image/png', + }, + }, + } ) + .then( ( response: any ) => { + if ( + response && + typeof response === 'object' && + 'image' in response + ) { + return response.image as { + id: number; + url: string; + title: string; + }; + } + + throw new Error( 'Invalid response from image import' ); + } ) + .catch( ( error ) => { + throw new Error( error.message ); + } ); +} From bf56c05cbf2dc71560d48287e6a2b0b760cf19e3 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 9 Dec 2025 12:05:51 -0700 Subject: [PATCH 10/54] Rename component file. Update TODO --- includes/Abilities/Image/Generate.php | 2 ++ ...te-featured-image.tsx => GenerateFeaturedImage.tsx} | 5 ++++- src/experiments/image-generation/featured-image.tsx | 2 +- .../image-generation/functions/generate-image.tsx | 10 ++++++++-- 4 files changed, 15 insertions(+), 4 deletions(-) rename src/experiments/image-generation/components/{generate-featured-image.tsx => GenerateFeaturedImage.tsx} (93%) diff --git a/includes/Abilities/Image/Generate.php b/includes/Abilities/Image/Generate.php index 233338c4..1fb7ca96 100644 --- a/includes/Abilities/Image/Generate.php +++ b/includes/Abilities/Image/Generate.php @@ -60,6 +60,8 @@ protected function output_schema(): array { * @since x.x.x */ protected function execute_callback( $input ) { + sleep( 1 ); + return '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='; // Generate the image. $result = $this->generate_image( $input['prompt'] ); diff --git a/src/experiments/image-generation/components/generate-featured-image.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx similarity index 93% rename from src/experiments/image-generation/components/generate-featured-image.tsx rename to src/experiments/image-generation/components/GenerateFeaturedImage.tsx index a587cab8..67cb4c04 100644 --- a/src/experiments/image-generation/components/generate-featured-image.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -28,6 +28,9 @@ import { uploadImage } from '../functions/upload-image'; * - Add middleware ability to take post context and generate prompt we can pass to image gen * - Styling to make generated image appear separate from featured image * - Look at creating functions for setting and removing the image. + * - Add meta to generated image to mark it as AI. + * - Add label below feaured image if AI generated (check if AI meta exists on that image). + * - When an image is generated, set that as featured image and show a regenerate icon overlay and remove generate button. */ /** @@ -84,7 +87,7 @@ export default function GenerateFeaturedImage(): JSX.Element { ); try { - const generatedImage = await generateImage( content ); + const generatedImage = await generateImage( postId, content ); const importedImage = await uploadImage( generatedImage ); editPost( { meta: { diff --git a/src/experiments/image-generation/featured-image.tsx b/src/experiments/image-generation/featured-image.tsx index dc4009a0..0647d688 100644 --- a/src/experiments/image-generation/featured-image.tsx +++ b/src/experiments/image-generation/featured-image.tsx @@ -12,7 +12,7 @@ import { addFilter } from '@wordpress/hooks'; /** * Internal dependencies */ -import GenerateFeaturedImage from './components/generate-featured-image'; +import GenerateFeaturedImage from './components/GenerateFeaturedImage'; const { aiImageGenerationData } = window as any; diff --git a/src/experiments/image-generation/functions/generate-image.tsx b/src/experiments/image-generation/functions/generate-image.tsx index 0b4fc0e6..1b39fef8 100644 --- a/src/experiments/image-generation/functions/generate-image.tsx +++ b/src/experiments/image-generation/functions/generate-image.tsx @@ -6,12 +6,18 @@ import apiFetch from '@wordpress/api-fetch'; const { aiImageGenerationData } = window as any; /** - * Generates an image for the given content. + * Generates an image for the given post ID and content. * + * @param {number} postId The ID of the post to generate a title for. * @param {string} content The content of the post to generate an image for. * @return {Promise} A promise that resolves to the generated image. */ -export async function generateImage( content: string ): Promise< string > { +export async function generateImage( + postId: number, + content: string +): Promise< string > { + // TODO: add a call to generate a prompt first and then pass that to the generate image function. + return apiFetch( { path: aiImageGenerationData?.generatePath ?? '', method: 'POST', From 64c743a6a790287134d84bfd48fb9068e6e7d971 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 12 Dec 2025 13:54:07 -0700 Subject: [PATCH 11/54] Use wp_delete_file instead of unlink --- includes/Abilities/Image/Import_Base64_Image.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Image/Import_Base64_Image.php b/includes/Abilities/Image/Import_Base64_Image.php index a3f76534..8e0c197c 100644 --- a/includes/Abilities/Image/Import_Base64_Image.php +++ b/includes/Abilities/Image/Import_Base64_Image.php @@ -242,7 +242,7 @@ protected function import_image( string $data, array $args = array() ) { $bytes_written = file_put_contents( $temp_file, $decoded_data ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents if ( false === $bytes_written ) { - @unlink( $temp_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Forbidden, WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_unlink + wp_delete_file( $temp_file ); return new WP_Error( 'write_failed', esc_html__( 'Failed to write image data to temporary file.', 'ai' ) @@ -276,7 +276,7 @@ protected function import_image( string $data, array $args = array() ) { // Clean up temp file if it still exists. if ( file_exists( $temp_file ) ) { - @unlink( $temp_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Generic.PHP.NoSilencedErrors.Forbidden, WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_unlink + wp_delete_file( $temp_file ); } // Ensure the import worked. From 9f6ea3e998c42369088a9fb50df2b2bd43a81903 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 12 Dec 2025 14:23:07 -0700 Subject: [PATCH 12/54] Automatically set the generated image as the featured image --- .../components/GenerateFeaturedImage.tsx | 110 ++++-------------- 1 file changed, 20 insertions(+), 90 deletions(-) diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx index 67cb4c04..9d4fb8ad 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -6,11 +6,11 @@ import React from 'react'; /** * WordPress dependencies */ -import { Button, __experimentalHStack as HStack } from '@wordpress/components'; +import { Button } from '@wordpress/components'; import { store as coreStore } from '@wordpress/core-data'; -import { dispatch, select, useDispatch, useSelect } from '@wordpress/data'; +import { dispatch, select, useDispatch } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; -import { useEffect, useState } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; @@ -22,15 +22,10 @@ import { uploadImage } from '../functions/upload-image'; /** * TODO: - * - Add ability to see full image in a modal or lightbox (or link to media library view MediaUpload component) - * - Wire up the set button (or think about auto-setting as featured image when generated) - * - Add regenerate button and wire it up + * - Show a regenerate icon overlay and wire it up * - Add middleware ability to take post context and generate prompt we can pass to image gen - * - Styling to make generated image appear separate from featured image - * - Look at creating functions for setting and removing the image. * - Add meta to generated image to mark it as AI. * - Add label below feaured image if AI generated (check if AI meta exists on that image). - * - When an image is generated, set that as featured image and show a regenerate icon overlay and remove generate button. */ /** @@ -48,34 +43,10 @@ export default function GenerateFeaturedImage(): JSX.Element { const meta = select( editorStore ).getEditedPostAttribute( 'meta' ); const postId = select( editorStore ).getCurrentPostId(); const postType = select( editorStore ).getCurrentPostType(); - const currentAiImageId = meta?.ai_featured_image; - - // See if we have an existing image to display. - const aiImage = useSelect( - ( selectStore ) => { - if ( ! currentAiImageId ) { - return null; - } - return selectStore( coreStore ).getEntityRecord( - 'postType', - 'attachment', - currentAiImageId - ); - }, - [ currentAiImageId ] - ); + const featuredImage = + select( editorStore ).getEditedPostAttribute( 'featured_media' ); const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); - const [ image, setImage ] = useState< string >( '' ); - - // Sync image state when entity record becomes available. - useEffect( () => { - if ( aiImage?.source_url ) { - setImage( aiImage.source_url ); - } else if ( ! currentAiImageId ) { - setImage( '' ); - } - }, [ aiImage, currentAiImageId ] ); /** * Handles the generate button click. @@ -90,81 +61,40 @@ export default function GenerateFeaturedImage(): JSX.Element { const generatedImage = await generateImage( postId, content ); const importedImage = await uploadImage( generatedImage ); editPost( { + featured_media: importedImage.id, meta: { ...meta, ai_featured_image: importedImage.id, }, } ); saveEditedEntityRecord( 'postType', postType, postId ); - setImage( importedImage.url ); } catch ( error: any ) { ( dispatch( noticesStore ) as any ).createErrorNotice( error, { id: 'ai_image_generation_error', isDismissible: true, } ); - setImage( '' ); } finally { setIsGenerating( false ); } }; return ( -
-
- { image && ( -
- { -
- ) } - { ! image && ( - - ) } - { !! image && ( - + <> + { ! featuredImage && ( +
+
- - - ) } -
-
+
+
+ ) } + ); } From a48de5ecd3ebc7dd1465fa408b15384c1b799570 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 12 Dec 2025 14:47:33 -0700 Subject: [PATCH 13/54] Add the ability to pass in custom meta fields when importing an image and add that as meta to the image --- .../Abilities/Image/Import_Base64_Image.php | 27 +++++++++++++++++++ .../Includes/Abilities/Image_ImportTest.php | 21 ++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/includes/Abilities/Image/Import_Base64_Image.php b/includes/Abilities/Image/Import_Base64_Image.php index 8e0c197c..1fdd5367 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/tests/Integration/Includes/Abilities/Image_ImportTest.php b/tests/Integration/Includes/Abilities/Image_ImportTest.php index b7219ac7..e57424b5 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' ); } /** From f07d6e86a0b9ab5023c124dac34f4511b502a6d4 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 12 Dec 2025 15:17:42 -0700 Subject: [PATCH 14/54] Set AI meta when the image is imported. Add a new component to render an AI label below the image and only show that if the featured image has the AI meta set --- .../Image_Generation/Image_Generation.php | 4 +- .../image-generation/components/AILabel.tsx | 63 +++++++++++++++++++ .../components/GenerateFeaturedImage.tsx | 2 - .../image-generation/featured-image.tsx | 5 +- .../functions/upload-image.tsx | 9 +++ 5 files changed, 78 insertions(+), 5 deletions(-) create mode 100644 src/experiments/image-generation/components/AILabel.tsx diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index f01b936c..3e360e99 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -54,8 +54,8 @@ public function register(): void { */ public function register_post_meta(): void { register_post_meta( - 'post', - 'ai_featured_image', + 'attachment', + 'ai_generated', array( 'type' => 'integer', 'single' => true, diff --git a/src/experiments/image-generation/components/AILabel.tsx b/src/experiments/image-generation/components/AILabel.tsx new file mode 100644 index 00000000..12f9a1a1 --- /dev/null +++ b/src/experiments/image-generation/components/AILabel.tsx @@ -0,0 +1,63 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * 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'; + +/** + * Displays a label indicating that the image was generated by AI. + * + * @param {string} label - The label to display. + * @return {JSX.Element} The AILabel component. + */ +export default function AILabel( { label }: { label: string } ): 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 index 9d4fb8ad..94d511f2 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -24,8 +24,6 @@ import { uploadImage } from '../functions/upload-image'; * TODO: * - Show a regenerate icon overlay and wire it up * - Add middleware ability to take post context and generate prompt we can pass to image gen - * - Add meta to generated image to mark it as AI. - * - Add label below feaured image if AI generated (check if AI meta exists on that image). */ /** diff --git a/src/experiments/image-generation/featured-image.tsx b/src/experiments/image-generation/featured-image.tsx index 0647d688..0b8c1705 100644 --- a/src/experiments/image-generation/featured-image.tsx +++ b/src/experiments/image-generation/featured-image.tsx @@ -8,11 +8,13 @@ import React from 'react'; */ 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; @@ -34,7 +36,8 @@ function wrapPostFeaturedImage( React.Fragment, {}, , - createElement( OriginalComponent, props ) + createElement( OriginalComponent, props ), + ); }; } diff --git a/src/experiments/image-generation/functions/upload-image.tsx b/src/experiments/image-generation/functions/upload-image.tsx index 64c207ee..7a911cf2 100644 --- a/src/experiments/image-generation/functions/upload-image.tsx +++ b/src/experiments/image-generation/functions/upload-image.tsx @@ -2,6 +2,7 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; +import { __ } from '@wordpress/i18n'; const { aiImageGenerationData } = window as any; @@ -23,6 +24,14 @@ export async function uploadImage( image: string ): Promise< { input: { data: image, mime_type: 'image/png', + title: __( 'AI Generated Image', 'ai' ), + description: __( 'This is an AI generated image.', 'ai' ), + meta: [ + { + key: 'ai_generated', + value: '1', + }, + ], }, }, } ) From bf276e6c7b0335fe443e87ceb3c0c2c64f6d818b Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 12 Dec 2025 15:19:31 -0700 Subject: [PATCH 15/54] Remove code that set AI post meta as we now set that on the attachment --- .../components/GenerateFeaturedImage.tsx | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx index 94d511f2..1c05482f 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -7,7 +7,6 @@ import React from 'react'; * WordPress dependencies */ import { Button } from '@wordpress/components'; -import { store as coreStore } from '@wordpress/core-data'; import { dispatch, select, useDispatch } from '@wordpress/data'; import { store as editorStore } from '@wordpress/editor'; import { useState } from '@wordpress/element'; @@ -35,12 +34,9 @@ import { uploadImage } from '../functions/upload-image'; */ export default function GenerateFeaturedImage(): JSX.Element { const { editPost } = useDispatch( editorStore ); - const { saveEditedEntityRecord } = useDispatch( coreStore ); const content = select( editorStore ).getEditedPostContent(); - const meta = select( editorStore ).getEditedPostAttribute( 'meta' ); const postId = select( editorStore ).getCurrentPostId(); - const postType = select( editorStore ).getCurrentPostType(); const featuredImage = select( editorStore ).getEditedPostAttribute( 'featured_media' ); @@ -60,12 +56,7 @@ export default function GenerateFeaturedImage(): JSX.Element { const importedImage = await uploadImage( generatedImage ); editPost( { featured_media: importedImage.id, - meta: { - ...meta, - ai_featured_image: importedImage.id, - }, } ); - saveEditedEntityRecord( 'postType', postType, postId ); } catch ( error: any ) { ( dispatch( noticesStore ) as any ).createErrorNotice( error, { id: 'ai_image_generation_error', From 7c434ce29cf68b51f05a3c2a034a675846b626c6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 16 Dec 2025 13:04:25 -0700 Subject: [PATCH 16/54] Show generate button even if we have a featured image set but change the text in that case --- .../components/GenerateFeaturedImage.tsx | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx index 1c05482f..018885d0 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -42,6 +42,10 @@ export default function GenerateFeaturedImage(): JSX.Element { const [ isGenerating, setIsGenerating ] = useState< boolean >( false ); + const buttonLabel = featuredImage + ? __( 'Generate new featured image', 'ai' ) + : __( 'Generate featured image', 'ai' ); + /** * Handles the generate button click. */ @@ -68,22 +72,18 @@ export default function GenerateFeaturedImage(): JSX.Element { }; return ( - <> - { ! featuredImage && ( -
-
- -
-
- ) } - +
+
+ +
+
); } From 197583316fbe222ebc7ae57cc2a05426a811b23a Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 16 Dec 2025 13:43:06 -0700 Subject: [PATCH 17/54] Add a general prompt creation ability that can be used to generate LLM ready prompts --- includes/Abilities/Utilities/Prompts.php | 119 +++++++++++++++++++++++ includes/bootstrap.php | 5 + 2 files changed, 124 insertions(+) create mode 100644 includes/Abilities/Utilities/Prompts.php diff --git a/includes/Abilities/Utilities/Prompts.php b/includes/Abilities/Utilities/Prompts.php new file mode 100644 index 00000000..522fa410 --- /dev/null +++ b/includes/Abilities/Utilities/Prompts.php @@ -0,0 +1,119 @@ +register_generate_prompt_ability(); + } + + /** + * Registers the generate-prompt ability. + * + * @since x.x.x + */ + private function register_generate_prompt_ability(): void { + wp_register_ability( + 'ai/generate-prompt', + array( + 'label' => esc_html__( 'Generate a prompt', 'ai' ), + 'description' => esc_html__( 'Generate a prompt for a specific purpose.', 'ai' ), + 'category' => AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY, + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'purpose' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The intended purpose for the prompt.', 'ai' ), + ), + 'context' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Any additional context to help generate the prompt.', 'ai' ), + ), + ), + 'required' => array( 'purpose', 'context' ), + ), + 'output_schema' => array( + 'type' => 'string', + 'description' => esc_html__( 'The generated prompt.', 'ai' ), + ), + 'execute_callback' => static function ( array $input ) { + // Default arguments. + $args = wp_parse_args( + $input, + array( + 'purpose' => '', + 'context' => '', + ), + ); + + $content = "## Purpose\n" . $args['purpose']; + + // If context is provided, add it to the content. + if ( ! empty( $args['context'] ) ) { + $content .= "\n\n## Context\n" . $args['context']; + } + + // Generate the prompt using the AI client. + return AI_Client::prompt_with_wp_error( '"""' . $content . '"""' ) + ->using_system_instruction( self::$prompt_generation_system_instruction ) + ->using_temperature( 0.9 ) + ->using_model_preference( ...get_preferred_models() ) + ->generate_text(); + }, + 'permission_callback' => 'is_user_logged_in', + 'meta' => array( + 'mcp' => array( + 'public' => true, + 'type' => 'prompt', + ), + ), + ) + ); + } +} diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 3ad96844..278210f0 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -12,6 +12,7 @@ namespace WordPress\AI; use WordPress\AI\Abilities\Utilities\Posts; +use WordPress\AI\Abilities\Utilities\Prompts; use WordPress\AI\Settings\Settings_Page; use WordPress\AI\Settings\Settings_Registration; use WordPress\AI_Client\AI_Client; @@ -223,6 +224,10 @@ function initialize_experiments(): void { $post_abilities = new Posts(); $post_abilities->register(); + // Register our prompt-related WordPress Abilities. + $prompt_abilities = new Prompts(); + $prompt_abilities->register(); + add_action( 'wp_abilities_api_categories_init', static function () { From dca091270aebe761d39971c0fd7c0c5572b478dc Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 16 Dec 2025 14:47:32 -0700 Subject: [PATCH 18/54] When featured image generation is triggered, first get additional context using the post ID. Then take that context and generate an image prompt. Finally pass that prompt into our image generation function. Also modify our system instructions a bit --- includes/Abilities/Utilities/Posts.php | 3 +- includes/Abilities/Utilities/Prompts.php | 19 ++++- .../Image_Generation/Image_Generation.php | 30 +++++++- .../components/GenerateFeaturedImage.tsx | 6 -- .../functions/format-context.ts | 23 ++++++ .../functions/generate-image.ts | 77 +++++++++++++++++++ .../functions/generate-image.tsx | 40 ---------- .../functions/generate-prompt.ts | 35 +++++++++ .../image-generation/functions/get-context.ts | 40 ++++++++++ .../image-generation/functions/strip-html.ts | 14 ++++ 10 files changed, 234 insertions(+), 53 deletions(-) create mode 100644 src/experiments/image-generation/functions/format-context.ts create mode 100644 src/experiments/image-generation/functions/generate-image.ts delete mode 100644 src/experiments/image-generation/functions/generate-image.tsx create mode 100644 src/experiments/image-generation/functions/generate-prompt.ts create mode 100644 src/experiments/image-generation/functions/get-context.ts create mode 100644 src/experiments/image-generation/functions/strip-html.ts diff --git a/includes/Abilities/Utilities/Posts.php b/includes/Abilities/Utilities/Posts.php index b272f819..6465819b 100644 --- a/includes/Abilities/Utilities/Posts.php +++ b/includes/Abilities/Utilities/Posts.php @@ -154,7 +154,8 @@ private function register_get_post_details_ability(): void { }, 'permission_callback' => array( $this, 'permission_callback' ), 'meta' => array( - 'mcp' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true, 'type' => 'tool', ), diff --git a/includes/Abilities/Utilities/Prompts.php b/includes/Abilities/Utilities/Prompts.php index 522fa410..756aa50d 100644 --- a/includes/Abilities/Utilities/Prompts.php +++ b/includes/Abilities/Utilities/Prompts.php @@ -27,9 +27,21 @@ class Prompts { * @var string */ private static string $prompt_generation_system_instruction = <<<'INSTRUCTION' -You are a helpful assistant that generates LLM-friendly prompts for a specific purpose. The intended purpose for the prompt will be provided as well as additional context that should be used to generate the prompt. This context will be provided in a structured format, with each key-value pair being a separate line. +You are a helpful assistant that generates LLM-ready system prompts for a specific downstream purpose. -Your job is to generate a prompt that can be used as a system instruction for an LLM. Only return this prompt, do not include any other text. The purpose and context will be delimited by triple quotes. +You will be given: +- A prompt purpose, describing what the downstream LLM should do +- Additional context, provided in a structured, line-by-line key-value format + +The purpose and context will be delimited by triple quotes. + +Your task is to synthesize this information into a single, complete system prompt that can be passed directly to another LLM to accomplish the stated purpose. + +Requirements: +- Incorporate relevant context faithfully and accurately +- Do not reference the existence or structure of the input context +- Do not include explanations, headings, or commentary +- Output only the final system prompt text INSTRUCTION; /** @@ -108,7 +120,8 @@ private function register_generate_prompt_ability(): void { }, 'permission_callback' => 'is_user_logged_in', 'meta' => array( - 'mcp' => array( + 'show_in_rest' => true, + 'mcp' => array( 'public' => true, 'type' => 'prompt', ), diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index 3e360e99..1b0c7a48 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -21,6 +21,27 @@ */ class Image_Generation extends Abstract_Experiment { + /** + * The purpose used to generate a prompt. + * + * @since x.x.x + * @var string + */ + private static string $prompt_generation_purpose = <<<'INSTRUCTION' +Analyze the information below and generate a single, self-contained image generation prompt suitable for use with an image generation model. + +The generated prompt should describe a featured image that visually represents the article's core topic and tone. Use the provided content as factual grounding, but do not include text, captions, logos, or branding in the image unless explicitly specified. + +The prompt should: +- Be written as a direct instruction to an image generation model +- Clearly describe the subject, setting, and visual style +- Reflect the article's theme and context without being overly literal +- Avoid mentioning the article, author, or website +- Be concise but descriptive enough to produce a high-quality, editorial-style image + +Output only the final image prompt, with no explanations or additional commentary. +INSTRUCTION; + /** * {@inheritDoc} * @@ -117,9 +138,12 @@ public function enqueue_assets( string $hook_suffix ): void { 'image_generation', 'ImageGenerationData', array( - 'enabled' => $this->is_enabled(), - 'generatePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', - 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', + 'enabled' => $this->is_enabled(), + 'generatePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', + 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', + 'getContextPath' => 'wp-abilities/v1/abilities/ai/get-post-details/run', + 'generatePromptPath' => 'wp-abilities/v1/abilities/ai/generate-prompt/run', + 'generatePromptPurpose' => self::$prompt_generation_purpose, ) ); } diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx index 018885d0..9ce62c0b 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -19,12 +19,6 @@ import { store as noticesStore } from '@wordpress/notices'; import { generateImage } from '../functions/generate-image'; import { uploadImage } from '../functions/upload-image'; -/** - * TODO: - * - Show a regenerate icon overlay and wire it up - * - Add middleware ability to take post context and generate prompt we can pass to image gen - */ - /** * GenerateFeaturedImage component. * 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..747a304e --- /dev/null +++ b/src/experiments/image-generation/functions/format-context.ts @@ -0,0 +1,23 @@ +/** + * Formats an object as a string with "Key: Value" pairs on separate lines. + * + * @param {Record} obj The object to format. + * @return {string} The formatted string. + */ +export function formatContext( + obj: Record< string, string | undefined > +): 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..376b3c7b --- /dev/null +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -0,0 +1,77 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { formatContext } from './format-context'; +import { getContext } from './get-context'; +import { generatePrompt } from './generate-prompt'; +import { stripHTML } from './strip-html'; + +const { aiImageGenerationData } = window as any; + +/** + * 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. + * @return {Promise} A promise that resolves to the generated image. + */ +export async function generateImage( + postId: number, + content: string +): Promise< string > { + let context: { + title: string; + type: string; + content?: string; + }; + + try { + context = ( await getContext( postId ) ) as { + title: string; + type: string; + content?: string; + }; + } catch ( error: any ) { + throw new Error( + `Failed to get post context: ${ error.message || error }` + ); + } + + // Remove HTML from the content to limit the amount of tokens we use. + context.content = stripHTML( content ); + + let prompt: string; + + try { + prompt = await generatePrompt( formatContext( context ) ); + } catch ( error: any ) { + throw new Error( + `Failed to generate prompt: ${ error.message || error }` + ); + } + + return apiFetch( { + path: aiImageGenerationData?.generatePath ?? '', + method: 'POST', + data: { + input: { + prompt, + }, + }, + } ) + .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/generate-image.tsx b/src/experiments/image-generation/functions/generate-image.tsx deleted file mode 100644 index 1b39fef8..00000000 --- a/src/experiments/image-generation/functions/generate-image.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; - -const { aiImageGenerationData } = window as any; - -/** - * Generates an image for the given post ID and content. - * - * @param {number} postId The ID of the post to generate a title for. - * @param {string} content The content of the post to generate an image for. - * @return {Promise} A promise that resolves to the generated image. - */ -export async function generateImage( - postId: number, - content: string -): Promise< string > { - // TODO: add a call to generate a prompt first and then pass that to the generate image function. - - return apiFetch( { - path: aiImageGenerationData?.generatePath ?? '', - method: 'POST', - data: { - input: { - prompt: content, - }, - }, - } ) - .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/generate-prompt.ts b/src/experiments/image-generation/functions/generate-prompt.ts new file mode 100644 index 00000000..634be729 --- /dev/null +++ b/src/experiments/image-generation/functions/generate-prompt.ts @@ -0,0 +1,35 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +const { aiImageGenerationData } = window as any; + +/** + * Generates a featured image generation prompt for the given post ID and content. + * + * @param {string} context The context to generate a featured image prompt for. + * @return {Promise} A promise that resolves to the generated featured image prompt. + */ +export async function generatePrompt( context: string ): Promise< string > { + return apiFetch( { + path: aiImageGenerationData?.generatePromptPath ?? '', + method: 'POST', + data: { + input: { + purpose: aiImageGenerationData?.generatePromptPurpose, + context, + }, + }, + } ) + .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..c1203d1f --- /dev/null +++ b/src/experiments/image-generation/functions/get-context.ts @@ -0,0 +1,40 @@ +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +const { aiImageGenerationData } = window as any; + +/** + * Gets the context for the given post ID. + * + * @param {number} postId The ID of the post to get the context for. + * @return {Promise<{ title: string; type: string }>} A promise that resolves to the context. + */ +export async function getContext( + postId: number +): Promise< { title: string; type: string } > { + return apiFetch( { + path: aiImageGenerationData?.getContextPath ?? '', + method: 'POST', + data: { + input: { + post_id: postId, + fields: [ 'title', 'type' ], + }, + }, + } ) + .then( ( response ) => { + if ( response && typeof response === 'object' ) { + return response as { + title: string; + type: string; + }; + } + + throw new Error( 'Invalid response from get context' ); + } ) + .catch( ( error ) => { + throw new Error( error.message ); + } ); +} diff --git a/src/experiments/image-generation/functions/strip-html.ts b/src/experiments/image-generation/functions/strip-html.ts new file mode 100644 index 00000000..85b2d9a5 --- /dev/null +++ b/src/experiments/image-generation/functions/strip-html.ts @@ -0,0 +1,14 @@ +/** + * Strips HTML tags from a string, preserving text content. + * + * Also removes linebreaks from the text content. + * + * @param {string} html The HTML string to strip. + * @return {string} The text content without HTML tags. + */ +export function stripHTML( html: string ): string { + const tempDiv = document.createElement( 'div' ); + tempDiv.innerHTML = html; + const textContent = tempDiv.textContent || tempDiv.innerText || ''; + return textContent.replace( /\n/g, '' ); +} From e01934f014c9625f4da0e64b42ac95a2637385c5 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 16 Dec 2025 14:58:19 -0700 Subject: [PATCH 19/54] Increase the timeout value when generating an image. Remove test code --- includes/Abilities/Image/Generate_Image.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index dbca2bc1..adcc08b1 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -60,8 +60,6 @@ protected function output_schema(): array { * @since x.x.x */ protected function execute_callback( $input ) { - sleep( 1 ); - return '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='; // Generate the image. $result = $this->generate_image( $input['prompt'] ); @@ -119,6 +117,11 @@ protected function meta(): array { * @return string|\WP_Error The generated image data, or a WP_Error if there was an error. */ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.NamingConventions.ConstructorName.OldStyle + // Increase the default timeout as image generation can take a while. + add_filter( 'wp_ai_client_default_request_timeout', static function () { + return 90; + } ); + // Generate the image using the AI client. $file = AI_Client::prompt_with_wp_error( $prompt ) ->as_output_file_type( FileTypeEnum::inline() ) From 292cc69ef3e91402ea4d6f1e67d0f97b4d8804a3 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 16 Dec 2025 15:25:09 -0700 Subject: [PATCH 20/54] Add tests for the prompt ability --- .../Includes/Abilities/PromptsTest.php | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 tests/Integration/Includes/Abilities/PromptsTest.php diff --git a/tests/Integration/Includes/Abilities/PromptsTest.php b/tests/Integration/Includes/Abilities/PromptsTest.php new file mode 100644 index 00000000..642715fc --- /dev/null +++ b/tests/Integration/Includes/Abilities/PromptsTest.php @@ -0,0 +1,443 @@ +prompts = new Prompts(); + + // Register the ability category if it doesn't exist. + if ( function_exists( 'wp_register_ability_category' ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound + if ( ! function_exists( 'wp_get_ability_category' ) || ! \wp_get_ability_category( AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound + \wp_register_ability_category( + AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY, + array( + 'label' => __( 'AI Experiments', 'ai' ), + 'description' => __( 'Various AI experiments.', 'ai' ), + ) + ); + } + } + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that register() hooks into wp_abilities_api_init. + * + * @since x.x.x + */ + public function test_register_hooks_into_wp_abilities_api_init() { + // Clear any existing hooks. + remove_all_actions( 'wp_abilities_api_init' ); + + $this->prompts->register(); + + // Verify the hook was added. has_action returns priority (int) or false. + $has_action = has_action( 'wp_abilities_api_init', array( $this->prompts, 'register_abilities' ) ); + $this->assertNotFalse( + $has_action, + 'register() should hook register_abilities into wp_abilities_api_init' + ); + } + + /** + * Test that register_abilities() registers the generate-prompt ability. + * + * @since x.x.x + */ + public function test_register_abilities_registers_generate_prompt_ability() { + 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' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + $this->assertNotNull( $ability, 'Ability should be registered' ); + $this->assertInstanceOf( \WP_Ability::class, $ability, 'Should be a WP_Ability instance' ); + } + + /** + * Test that the ability has correct label and description. + * + * @since x.x.x + */ + public function test_ability_has_correct_label_and_description() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + $this->assertEquals( + 'Generate a prompt', + $ability->get_label(), + 'Label should match' + ); + $this->assertEquals( + 'Generate a prompt for a specific purpose.', + $ability->get_description(), + 'Description should match' + ); + } + + /** + * Test that the ability has correct category. + * + * @since x.x.x + */ + public function test_ability_has_correct_category() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + // Use reflection to access the category property. + $reflection = new \ReflectionClass( $ability ); + $property = $reflection->getProperty( 'category' ); + $property->setAccessible( true ); + + $this->assertEquals( + AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY, + $property->getValue( $ability ), + 'Category should match' + ); + } + + /** + * Test that input_schema has correct structure. + * + * @since x.x.x + */ + public function test_input_schema_has_correct_structure() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + $schema = $ability->get_input_schema(); + + $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( 'purpose', $schema['properties'], 'Schema should have purpose property' ); + $this->assertArrayHasKey( 'context', $schema['properties'], 'Schema should have context property' ); + $this->assertArrayHasKey( 'required', $schema, 'Schema should have required array' ); + $this->assertContains( 'purpose', $schema['required'], 'purpose should be required' ); + $this->assertContains( 'context', $schema['required'], 'context should be required' ); + + // Verify purpose property. + $this->assertEquals( 'string', $schema['properties']['purpose']['type'], 'Purpose should be string type' ); + $this->assertEquals( 'sanitize_text_field', $schema['properties']['purpose']['sanitize_callback'], 'Purpose 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' ); + } + + /** + * Test that output_schema has correct structure. + * + * @since x.x.x + */ + public function test_output_schema_has_correct_structure() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + $schema = $ability->get_output_schema(); + + $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 generated prompt.', $schema['description'], 'Description should match' ); + } + + /** + * Test that execute_callback formats content correctly with purpose and context. + * + * @since x.x.x + */ + public function test_execute_callback_formats_content_with_purpose_and_context() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + $input = array( + 'purpose' => 'Generate a featured image', + 'context' => 'Title: Test Post\nType: post', + ); + + try { + $result = $ability->execute( $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 formats content correctly with purpose only. + * + * @since x.x.x + */ + public function test_execute_callback_formats_content_with_purpose_only() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + $input = array( + 'purpose' => 'Generate a featured image', + 'context' => '', + ); + + try { + $result = $ability->execute( $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 uses default values for missing parameters. + * + * @since x.x.x + */ + public function test_execute_callback_uses_default_values() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + $input = array(); + + try { + $result = $ability->execute( $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; + } + + // Should still work with empty defaults. + $this->assertIsString( $result, 'Result should be a string' ); + } + + /** + * Test that permission_callback returns true for logged in user. + * + * @since x.x.x + */ + public function test_permission_callback_returns_true_for_logged_in_user() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + // Create a logged in user. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + // Use reflection to access the permission_callback. + $reflection = new \ReflectionClass( $ability ); + $property = $reflection->getProperty( 'permission_callback' ); + $property->setAccessible( true ); + $callback = $property->getValue( $ability ); + + $result = call_user_func( $callback, array() ); + + $this->assertTrue( $result, 'Permission should be granted for logged in user' ); + } + + /** + * Test that permission_callback returns false for logged out user. + * + * @since x.x.x + */ + public function test_permission_callback_returns_false_for_logged_out_user() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + // Ensure no user is logged in. + wp_set_current_user( 0 ); + + // Use reflection to access the permission_callback. + $reflection = new \ReflectionClass( $ability ); + $property = $reflection->getProperty( 'permission_callback' ); + $property->setAccessible( true ); + $callback = $property->getValue( $ability ); + + $result = call_user_func( $callback, array() ); + + $this->assertFalse( $result, 'Permission should be denied for logged out user' ); + } + + /** + * Test that meta has correct structure. + * + * @since x.x.x + */ + public function test_meta_has_correct_structure() { + 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' ); + + do_action( 'wp_abilities_api_init' ); + + $ability = wp_get_ability( 'ai/generate-prompt' ); + + // Use reflection to access the meta property. + $reflection = new \ReflectionClass( $ability ); + $property = $reflection->getProperty( 'meta' ); + $property->setAccessible( true ); + $meta = $property->getValue( $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' ); + } +} From 92b3e5655c12aa397d200603bb5a4947e0d2d7a3 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 16 Dec 2025 15:29:39 -0700 Subject: [PATCH 21/54] Fix PHPCS error --- includes/Abilities/Image/Generate_Image.php | 9 ++++++--- includes/Abilities/Utilities/Prompts.php | 1 + .../Experiments/Image_Generation/Image_Generation.php | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index adcc08b1..75ca76ea 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -118,9 +118,12 @@ protected function meta(): array { */ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.NamingConventions.ConstructorName.OldStyle // Increase the default timeout as image generation can take a while. - add_filter( 'wp_ai_client_default_request_timeout', static function () { - return 90; - } ); + add_filter( + 'wp_ai_client_default_request_timeout', + static function () { + return 90; + } + ); // Generate the image using the AI client. $file = AI_Client::prompt_with_wp_error( $prompt ) diff --git a/includes/Abilities/Utilities/Prompts.php b/includes/Abilities/Utilities/Prompts.php index 756aa50d..dcd7618a 100644 --- a/includes/Abilities/Utilities/Prompts.php +++ b/includes/Abilities/Utilities/Prompts.php @@ -26,6 +26,7 @@ class Prompts { * @since x.x.x * @var string */ + // phpcs:ignore Squiz.PHP.Heredoc.NotAllowed private static string $prompt_generation_system_instruction = <<<'INSTRUCTION' You are a helpful assistant that generates LLM-ready system prompts for a specific downstream purpose. diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index 1b0c7a48..e3c3bcd6 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -27,6 +27,7 @@ class Image_Generation extends Abstract_Experiment { * @since x.x.x * @var string */ + // phpcs:ignore Squiz.PHP.Heredoc.NotAllowed private static string $prompt_generation_purpose = <<<'INSTRUCTION' Analyze the information below and generate a single, self-contained image generation prompt suitable for use with an image generation model. From 764420408a594759bd4b32e08b265e7e89bc55d8 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 16 Dec 2025 16:43:41 -0700 Subject: [PATCH 22/54] Add support for the new gpt-image-1.5 model --- includes/helpers.php | 4 ++++ tests/Integration/Includes/HelpersTest.php | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/includes/helpers.php b/includes/helpers.php index f902c1db..414b4aa6 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -178,6 +178,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/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index be63fa50..1ca96181 100644 --- a/tests/Integration/Includes/HelpersTest.php +++ b/tests/Integration/Includes/HelpersTest.php @@ -356,7 +356,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 +380,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 fourth 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 +415,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' ); } From a5db5fca4dfec9a51c25ae6111ae8bada6161197 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 18 Dec 2025 09:10:27 -0700 Subject: [PATCH 23/54] Ensure files use proper extensions --- .../functions/{upload-image.tsx => upload-image.ts} | 0 src/experiments/image-generation/{index.tsx => index.ts} | 0 webpack.config.js | 2 +- 3 files changed, 1 insertion(+), 1 deletion(-) rename src/experiments/image-generation/functions/{upload-image.tsx => upload-image.ts} (100%) rename src/experiments/image-generation/{index.tsx => index.ts} (100%) diff --git a/src/experiments/image-generation/functions/upload-image.tsx b/src/experiments/image-generation/functions/upload-image.ts similarity index 100% rename from src/experiments/image-generation/functions/upload-image.tsx rename to src/experiments/image-generation/functions/upload-image.ts diff --git a/src/experiments/image-generation/index.tsx b/src/experiments/image-generation/index.ts similarity index 100% rename from src/experiments/image-generation/index.tsx rename to src/experiments/image-generation/index.ts diff --git a/webpack.config.js b/webpack.config.js index 380a3d8d..1bbce695 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -27,7 +27,7 @@ module.exports = { 'experiments/image-generation': path.resolve( process.cwd(), 'src/experiments/image-generation', - 'index.tsx' + 'index.ts' ), 'experiments/title-generation': path.resolve( process.cwd(), From 460f52974e8d5ee27854cd6f48196779bbe5b5dd Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 22 Dec 2025 13:56:52 -0700 Subject: [PATCH 24/54] Change how we increase the default request timeout --- includes/Abilities/Image/Generate_Image.php | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index 75ca76ea..b4eb139b 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -13,6 +13,7 @@ use WordPress\AI\Abstracts\Abstract_Ability; use WordPress\AI_Client\AI_Client; use WordPress\AiClient\Files\Enums\FileTypeEnum; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use function WordPress\AI\get_preferred_image_models; @@ -117,16 +118,12 @@ protected function meta(): array { * @return string|\WP_Error The generated image data, or a WP_Error if there was an error. */ protected function generate_image( string $prompt ) { // phpcs:ignore Generic.NamingConventions.ConstructorName.OldStyle - // Increase the default timeout as image generation can take a while. - add_filter( - 'wp_ai_client_default_request_timeout', - static function () { - return 90; - } - ); + $request_options = new RequestOptions(); + $request_options->setTimeout( 90 ); // Generate the image using the AI client. $file = AI_Client::prompt_with_wp_error( $prompt ) + ->using_request_options( $request_options ) ->as_output_file_type( FileTypeEnum::inline() ) ->using_model_preference( ...get_preferred_image_models() ) ->generate_image(); From c0a96a40a1a90f045ed29cebc5deca24a9ca5d2c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 2 Jan 2026 12:29:35 -0700 Subject: [PATCH 25/54] Add docs --- docs/experiments/image-generation.md | 694 +++++++++++++++++++++++++++ 1 file changed, 694 insertions(+) create mode 100644 docs/experiments/image-generation.md diff --git a/docs/experiments/image-generation.md b/docs/experiments/image-generation.md new file mode 100644 index 00000000..ca2794e4 --- /dev/null +++ b/docs/experiments/image-generation.md @@ -0,0 +1,694 @@ +# Image Generation + +## 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. + +## Overview + +### For End Users + +When enabled, the Image Generation experiment adds a "Generate featured image" button to the featured image panel in the WordPress post editor. Users can click this button to automatically generate a featured image suggestion based on the current post's title, content, and type. The generated image is automatically imported into the media library and set as the featured image. Images generated by AI are marked with an "AI Generated Featured Image" label. + +**Key Features:** + +- One-click featured image generation from post content +- 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 +- Works with any post type that supports featured images +- Visual indicator for AI-generated images + +### For Developers + +The experiment consists of three main components: + +1. **Experiment Class** (`WordPress\AI\Experiments\Image_Generation\Image_Generation`): Handles registration, asset enqueuing, UI integration, and post meta registration +2. **Generate Image Ability** (`WordPress\AI\Abilities\Image\Generate_Image`): Generates base64-encoded images from prompts using AI models +3. **Import Image Ability** (`WordPress\AI\Abilities\Image\Import_Base64_Image`): Imports base64-encoded images into the WordPress media library + +The experiment also leverages the `ai/generate-prompt` ability (from `WordPress\AI\Abilities\Utilities\Prompts`) to create optimized image generation prompts from post context. + +Both abilities can be called directly via REST API, making them useful for automation, bulk processing, or custom integrations. + +## Architecture & Implementation + +### Key Hooks & Entry Points + +- `WordPress\AI\Experiments\Image_Generation\Image_Generation::register()` wires everything once the experiment is enabled: + - `register_post_meta()` → registers `ai_generated` post meta for attachment post type + - `wp_abilities_api_init` → registers the `ai/image-generation` and `ai/image-import` abilities + - `admin_enqueue_scripts` → enqueues the React bundle on `post.php` and `post-new.php` screens for post types that support featured images + +### Assets & Data Flow + +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 + - `generatePath`: REST API path to image generation ability (`/wp-json/wp-abilities/v1/abilities/ai/image-generation/run`) + - `importPath`: REST API path to image import ability (`/wp-json/wp-abilities/v1/abilities/ai/image-import/run`) + - `getContextPath`: REST API path to get post details (`/wp-json/wp-abilities/v1/abilities/ai/get-post-details/run`) + - `generatePromptPath`: REST API path to prompt generation ability (`/wp-json/wp-abilities/v1/abilities/ai/generate-prompt/run`) + - `generatePromptPurpose`: System instruction for generating image prompts + +2. **React Side:** + - The React entry point (`index.ts`) imports `featured-image.tsx` which hooks into the featured image panel using the `editor.PostFeaturedImage` filter + - `GenerateFeaturedImage` component renders a button that: + - Gets current post ID and content from the editor store + - Calls `generateImage()` function which: + - Gets post context (title, type) via `getContext()` + - Strips HTML from content + - Formats context and calls `generatePrompt()` to create an image generation prompt + - Calls the image generation ability with the 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 + - Returns attachment data (id, url, title) + - Updates the editor store to set the imported image as featured image + - Handles loading states and error notifications + - `AILabel` component displays a label for AI-generated images by checking the `ai_generated` meta + +3. **Ability Execution Flow:** + - **Prompt Generation** (via `ai/generate-prompt`): + - Accepts `purpose` (system instruction) and `context` (formatted post data) + - Uses AI to generate an optimized image generation prompt + - Returns a plain text prompt string + - **Image Generation** (via `ai/image-generation`): + - Accepts `prompt` (string) as input + - Uses AI image generation models (via `get_preferred_image_models()`) + - Returns base64-encoded image data + - **Image Import** (via `ai/image-import`): + - Accepts base64 image data and metadata (filename, title, description, alt_text, mime_type, meta) + - Decodes base64 data and creates temporary file + - Uses WordPress `media_handle_sideload()` to import into media library + - Sets attachment metadata and custom meta (like `ai_generated`) + - Returns attachment data (id, url, filename, title, description, alt_text) + +### Input Schemas + +#### Image Generation Ability + +```php +array( + 'type' => 'object', + 'properties' => array( + 'prompt' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Prompt used to generate an image.', + ), + ), + 'required' => array( 'prompt' ), +) +``` + +#### Image Import Ability + +```php +array( + 'type' => 'object', + 'properties' => array( + 'data' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The base64 encoded image data to import into the media library.', + ), + 'filename' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The filename of the image.', + ), + 'title' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The title of the image.', + ), + 'description' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The description of the image.', + ), + 'alt_text' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The alt text of the image.', + ), + 'mime_type' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The MIME type of the image.', + ), + 'meta' => array( + 'type' => 'array', + 'description' => 'Optional meta data to save with the image.', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'key' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'description' => 'The key of the meta data.', + ), + 'value' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The value of the meta data.', + ), + ), + 'required' => array( 'key', 'value' ), + 'additionalProperties' => false, + ), + ), + ), + 'required' => array( 'data' ), +) +``` + +### Output Schemas + +#### Image Generation Ability + +The ability returns a plain text string containing base64-encoded image data: + +```php +array( + 'type' => 'string', + 'description' => 'The base64 encoded image data.', +) +``` + +#### Image Import Ability + +The ability returns an object with image data: + +```php +array( + 'type' => 'object', + 'properties' => array( + 'image' => array( + 'type' => 'object', + 'description' => 'Imported image data.', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + 'description' => 'Attachment ID.', + ), + 'url' => array( + 'type' => 'string', + 'description' => 'Attachment URL.', + ), + 'filename' => array( + 'type' => 'string', + 'description' => 'Attachment filename.', + ), + 'title' => array( + 'type' => 'string', + 'description' => 'Attachment title.', + ), + 'description' => array( + 'type' => 'string', + 'description' => 'Attachment description.', + ), + 'alt_text' => array( + 'type' => 'string', + 'description' => 'Attachment alt text.', + ), + ), + ), + ), +) +``` + +### Permissions + +Both abilities check permissions: + +- **Image Generation:** Requires `current_user_can( 'upload_files' )` +- **Image Import:** Requires `current_user_can( 'upload_files' )` + +## Using the Abilities via REST API + +Both the image generation and image import abilities can be called directly via REST API, making them useful for automation, bulk processing, or custom integrations. + +### Endpoints + +```text +POST /wp-json/wp-abilities/v1/abilities/ai/image-generation/run +POST /wp-json/wp-abilities/v1/abilities/ai/image-import/run +``` + +### Authentication + +You can authenticate using either: + +1. **Application Password** (Recommended) +2. **Cookie Authentication with Nonce** + +See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication instructions. + +### Request Examples + +#### Example 1: Generate Image from Prompt + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-generation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "prompt": "A serene mountain landscape at sunset with a lake in the foreground, photorealistic style" + } + }' +``` + +**Response:** + +```json +"iVBORw0KGgoAAAANSUhEUgAA..." +``` + +The response is a base64-encoded string of the image data. + +#### Example 2: Import Base64 Image into Media Library + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-import/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "data": "iVBORw0KGgoAAAANSUhEUgAA...", + "filename": "mountain-landscape", + "title": "Mountain Landscape", + "description": "A beautiful mountain landscape at sunset", + "alt_text": "Mountain landscape with lake at sunset", + "mime_type": "image/png", + "meta": [ + { + "key": "ai_generated", + "value": "1" + } + ] + } + }' +``` + +**Response:** + +```json +{ + "image": { + "id": 123, + "url": "https://yoursite.com/wp-content/uploads/2025/01/mountain-landscape.png", + "filename": "mountain-landscape.png", + "title": "Mountain Landscape", + "description": "A beautiful mountain landscape at sunset", + "alt_text": "Mountain landscape with lake at sunset" + } +} +``` + +#### Example 3: Complete Flow - Generate and Import Image + +```bash +# Step 1: Generate the image +GENERATED_IMAGE=$(curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-generation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "prompt": "A modern office workspace with plants and natural lighting" + } + }') + +# Step 2: Import the image +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-import/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d "{ + \"input\": { + \"data\": \"$GENERATED_IMAGE\", + \"filename\": \"office-workspace\", + \"title\": \"Modern Office Workspace\", + \"description\": \"AI generated image of a modern office workspace\", + \"alt_text\": \"Modern office workspace with plants\", + \"mime_type\": \"image/png\", + \"meta\": [ + { + \"key\": \"ai_generated\", + \"value\": \"1\" + } + ] + } + }" +``` + +#### Example 4: Using JavaScript (Fetch API) + +```javascript +async function generateAndImportImage(prompt, filename, title) { + // Step 1: Generate image + const generateResponse = await fetch( + '/wp-json/wp-abilities/v1/abilities/ai/image-generation/run', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpApiSettings.nonce, + }, + credentials: 'include', + body: JSON.stringify({ + input: { prompt }, + }), + } + ); + + if (!generateResponse.ok) { + const error = await generateResponse.json(); + throw new Error(error.message || 'Failed to generate image'); + } + + const base64Image = await generateResponse.text(); + + // Step 2: Import image + const importResponse = await fetch( + '/wp-json/wp-abilities/v1/abilities/ai/image-import/run', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpApiSettings.nonce, + }, + credentials: 'include', + body: JSON.stringify({ + input: { + data: base64Image, + filename: filename, + title: title, + description: 'AI generated image', + alt_text: title, + mime_type: 'image/png', + meta: [ + { + key: 'ai_generated', + value: '1', + }, + ], + }, + }), + } + ); + + if (!importResponse.ok) { + const error = await importResponse.json(); + throw new Error(error.message || 'Failed to import image'); + } + + const result = await importResponse.json(); + return result.image; +} + +// Usage +generateAndImportImage( + 'A futuristic cityscape at night', + 'futuristic-city', + 'Futuristic Cityscape' +) + .then(image => console.log('Imported image:', image)) + .catch(error => console.error('Error:', error)); +``` + +#### Example 5: Using WordPress API Fetch (in Gutenberg/Admin) + +```javascript +import apiFetch from '@wordpress/api-fetch'; + +async function generateAndImportImage(prompt, filename, title) { + try { + // Step 1: Generate image + const base64Image = await apiFetch({ + path: '/wp-abilities/v1/abilities/ai/image-generation/run', + method: 'POST', + data: { + input: { prompt }, + }, + }); + + // Step 2: Import image + const result = await apiFetch({ + path: '/wp-abilities/v1/abilities/ai/image-import/run', + method: 'POST', + data: { + input: { + data: base64Image, + filename: filename, + title: title, + description: 'AI generated image', + alt_text: title, + mime_type: 'image/png', + meta: [ + { + key: 'ai_generated', + value: '1', + }, + ], + }, + }, + }); + + return result.image; + } catch (error) { + console.error('Error generating/importing image:', error); + throw error; + } +} +``` + +### Error Responses + +The abilities may return the following error codes: + +**Image Generation:** + +- `no_results`: The AI client did not return any results +- `no_image_data`: The generated image data is empty +- `insufficient_capabilities`: The current user does not have permission to generate images + +**Image Import:** + +- `invalid_data`: The provided data is not a valid base64 encoded string or is not a valid image +- `no_base64_data`: No base64 data found in the provided input +- `invalid_base64`: Failed to decode base64 image data +- `write_failed`: Failed to write image data to temporary file +- `attachment_not_found`: Failed to retrieve attachment data after import +- `insufficient_capabilities`: The current user does not have permission to import images + +Example error response: + +```json +{ + "code": "invalid_data", + "message": "The data is not a valid base64 encoded string.", + "data": { + "status": 400 + } +} +``` + +## Extending the Experiment + +### Customizing the Prompt Generation Purpose + +The system instruction that guides prompt generation can be customized by modifying the `$prompt_generation_purpose` property in: + +```php +includes/Experiments/Image_Generation/Image_Generation.php +``` + +This instruction tells the AI how to create image generation prompts from post context. You can modify it to change the style, tone, or requirements for generated prompts. + +### Filtering Preferred Image Models + +You can filter which AI image models are used for image generation using the `ai_experiments_preferred_image_models` filter: + +```php +add_filter( 'ai_experiments_preferred_image_models', function( $models ) { + // Prefer specific image models + return array( + array( 'openai', 'dall-e-3' ), + array( 'openai', 'gpt-image-1' ), + array( 'google', 'imagen-4.0-generate-001' ), + ); +} ); +``` + +### Customizing Image Import Metadata + +You can customize what metadata is saved when importing images by modifying the `uploadImage` function in: + +```typescript +src/experiments/image-generation/functions/upload-image.ts +``` + +Or by filtering the input before calling the import ability via REST API. + +### Customizing Post Context + +The experiment uses `getContext()` to fetch post details (title, type). You can extend this to include additional context by modifying: + +```typescript +src/experiments/image-generation/functions/get-context.ts +``` + +### Adding Custom UI Elements + +You can extend the React components to add custom UI elements: + +1. **Modify the generate button component:** + - Edit `src/experiments/image-generation/components/GenerateFeaturedImage.tsx` + +2. **Customize the AI label:** + - Edit `src/experiments/image-generation/components/AILabel.tsx` + +3. **Add custom functions:** + - Create new functions in `src/experiments/image-generation/functions/` + - Import and use them in the components + +4. **Customize the featured image panel:** + - The experiment uses the `editor.PostFeaturedImage` filter to inject into the featured image panel + - You can modify `src/experiments/image-generation/featured-image.tsx` to add additional UI + +### Customizing Image Processing + +The image import process uses WordPress's `media_handle_sideload()` function. You can hook into WordPress media upload filters to customize the import process: + +```php +// Customize attachment data before saving +add_filter( 'wp_insert_attachment_data', function( $data, $postarr ) { + // Customize attachment data + return $data; +}, 10, 2 ); + +// Customize attachment metadata +add_filter( 'wp_generate_attachment_metadata', function( $metadata, $attachment_id ) { + // Customize metadata + return $metadata; +}, 10, 2 ); +``` + +## Testing + +### Manual Testing + +1. **Enable the experiment:** + - Go to `Settings → AI Experiments` + - Toggle **Image Generation** to enabled + - Ensure you have valid AI credentials configured + +2. **Test in the editor:** + - Create or edit a post with content + - Scroll to the featured image panel + - Click the "Generate featured image" button + - 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 + +3. **Test with different post types:** + - The experiment only loads for post types that support featured images (`post_type_supports( $post_type, 'thumbnail' )`) + - Test with posts, pages, and custom post types that have featured image support + +4. **Test REST API:** + - Use curl or Postman to test both endpoints + - Verify authentication works + - Test image generation with different prompts + - Test image import with different metadata + - Test the complete flow (generate then import) + - Verify error handling for invalid inputs + +### Automated Testing + +Unit tests are located in: + +- `tests/Integration/Includes/Abilities/Image_GenerationTest.php` +- `tests/Integration/Includes/Experiments/Image_Generation/Image_GenerationTest.php` + +Run tests with: + +```bash +npm run test:php +``` + +## Notes & Considerations + +### Requirements + +- The experiment requires valid AI credentials to be configured +- The experiment only works for post types that support featured images (`post_type_supports( $post_type, 'thumbnail' )`) +- Users must have `upload_files` capability +- The experiment requires image generation models to be available (configured via `get_preferred_image_models()`) + +### 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 +- 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 + +### Image Processing + +- Images are generated as base64-encoded strings +- The import process decodes base64, creates a temporary file, and uses WordPress media functions to import +- Temporary files are automatically cleaned up after import +- File extension is determined from MIME type using `wp_get_default_extension_for_mime_type()` + +### AI Model Selection + +- The ability uses `get_preferred_image_models()` to determine which AI image models to use +- Models are tried in order until one succeeds +- Default models include Google's Gemini image models and OpenAI's DALL-E and GPT-image models +- Request timeout is set to 90 seconds to accommodate longer image generation times + +### Prompt Generation + +- The experiment uses a two-step process: + 1. First, it generates an optimized image generation prompt from post context using the `ai/generate-prompt` ability + 2. Then, it uses that prompt to generate the actual image +- The prompt generation purpose is defined in the experiment class and instructs the AI on how to create image prompts +- The generated prompt is designed to be suitable for image generation models and reflects the article's core topic and tone + +### Image Metadata + +- Imported images are marked with `ai_generated` post meta (set to `1`) +- This meta is registered for the `attachment` post type and is available in REST API +- The `AILabel` component checks this meta to display the AI-generated label +- Additional custom meta can be passed via the `meta` parameter in the import ability + +### Limitations + +- Images are generated in real-time and not cached +- The ability does not support batch processing (one image per request) +- Generated images are suggestions and should be reviewed before publishing +- The experiment requires JavaScript to be enabled in the admin +- Image generation may fail if AI models are unavailable or rate-limited +- Base64 image data can be very large; ensure adequate server resources + +### Security Considerations + +- Base64 image data is validated before import +- File types are validated using MIME type checking +- 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 Ability:** `includes/Abilities/Image/Generate_Image.php` +- **Import Image Ability:** `includes/Abilities/Image/Import_Base64_Image.php` +- **Prompt Generation Ability:** `includes/Abilities/Utilities/Prompts.php` +- **React Entry:** `src/experiments/image-generation/index.ts` +- **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` From 78d141d7cea1d34c7b9995c5a2cd06af2b614576 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 2 Jan 2026 13:56:42 -0700 Subject: [PATCH 26/54] Add E2E tests --- .../e2e-request-mocking.php | 5 + .../responses/OpenAI/image.json | 8 + .../experiments/image-generation.spec.js | 161 ++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 tests/e2e-request-mocking/responses/OpenAI/image.json create mode 100644 tests/e2e/specs/experiments/image-generation.spec.js 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..e70576d6 --- /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', + 'AI Generated Image' + ); + + 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(); + } ); +} ); From e2c1dfe47da0462c145bdba7c08493f75ed8e9ab Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Jan 2026 09:19:48 -0700 Subject: [PATCH 27/54] Remove the generic Prompt Ability and instead register it as a specific Ability for image generation --- includes/Abilities/Image/Generate_Prompt.php | 139 ++++++++++++++++++ includes/Abilities/Utilities/Prompts.php | 133 ----------------- .../Image_Generation/Image_Generation.php | 10 ++ includes/bootstrap.php | 5 - 4 files changed, 149 insertions(+), 138 deletions(-) create mode 100644 includes/Abilities/Image/Generate_Prompt.php delete mode 100644 includes/Abilities/Utilities/Prompts.php diff --git a/includes/Abilities/Image/Generate_Prompt.php b/includes/Abilities/Image/Generate_Prompt.php new file mode 100644 index 00000000..d635f81f --- /dev/null +++ b/includes/Abilities/Image/Generate_Prompt.php @@ -0,0 +1,139 @@ + 'object', + 'properties' => array( + 'purpose' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The intended purpose for the prompt.', 'ai' ), + ), + 'context' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'Any additional context to help generate the prompt.', 'ai' ), + ), + ), + 'required' => array( 'purpose', 'context' ), + ); + } + + /** + * {@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( + 'purpose' => '', + 'context' => '', + ), + ); + + $content = "## Purpose\n" . $args['purpose']; + + // If context is provided, add it to the content. + if ( ! empty( $args['context'] ) ) { + $content .= "\n\n## Context\n" . $args['context']; + } + + // Generate the prompt using the AI client. + return AI_Client::prompt_with_wp_error( '"""' . $content . '"""' ) + ->using_system_instruction( self::$prompt_generation_system_instruction ) + ->using_temperature( 0.9 ) + ->using_model_preference( ...get_preferred_models() ) + ->generate_text(); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + // Ensure the user is logged in. + return is_user_logged_in(); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'prompt', + ), + ); + } +} diff --git a/includes/Abilities/Utilities/Prompts.php b/includes/Abilities/Utilities/Prompts.php deleted file mode 100644 index dcd7618a..00000000 --- a/includes/Abilities/Utilities/Prompts.php +++ /dev/null @@ -1,133 +0,0 @@ -register_generate_prompt_ability(); - } - - /** - * Registers the generate-prompt ability. - * - * @since x.x.x - */ - private function register_generate_prompt_ability(): void { - wp_register_ability( - 'ai/generate-prompt', - array( - 'label' => esc_html__( 'Generate a prompt', 'ai' ), - 'description' => esc_html__( 'Generate a prompt for a specific purpose.', 'ai' ), - 'category' => AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY, - 'input_schema' => array( - 'type' => 'object', - 'properties' => array( - 'purpose' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => esc_html__( 'The intended purpose for the prompt.', 'ai' ), - ), - 'context' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => esc_html__( 'Any additional context to help generate the prompt.', 'ai' ), - ), - ), - 'required' => array( 'purpose', 'context' ), - ), - 'output_schema' => array( - 'type' => 'string', - 'description' => esc_html__( 'The generated prompt.', 'ai' ), - ), - 'execute_callback' => static function ( array $input ) { - // Default arguments. - $args = wp_parse_args( - $input, - array( - 'purpose' => '', - 'context' => '', - ), - ); - - $content = "## Purpose\n" . $args['purpose']; - - // If context is provided, add it to the content. - if ( ! empty( $args['context'] ) ) { - $content .= "\n\n## Context\n" . $args['context']; - } - - // Generate the prompt using the AI client. - return AI_Client::prompt_with_wp_error( '"""' . $content . '"""' ) - ->using_system_instruction( self::$prompt_generation_system_instruction ) - ->using_temperature( 0.9 ) - ->using_model_preference( ...get_preferred_models() ) - ->generate_text(); - }, - 'permission_callback' => 'is_user_logged_in', - 'meta' => array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - 'type' => 'prompt', - ), - ), - ) - ); - } -} diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index e3c3bcd6..156d9ce6 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -11,6 +11,7 @@ use WordPress\AI\Abilities\Image\Generate_Image as Image_Generation_Ability; use WordPress\AI\Abilities\Image\Import_Base64_Image as Image_Import_Ability; +use WordPress\AI\Abilities\Image\Generate_Prompt as Generate_Prompt_Ability; use WordPress\AI\Abstracts\Abstract_Experiment; use WordPress\AI\Asset_Loader; @@ -109,6 +110,15 @@ 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_Prompt_Ability::class, + ), + ); } /** diff --git a/includes/bootstrap.php b/includes/bootstrap.php index 278210f0..3ad96844 100644 --- a/includes/bootstrap.php +++ b/includes/bootstrap.php @@ -12,7 +12,6 @@ namespace WordPress\AI; use WordPress\AI\Abilities\Utilities\Posts; -use WordPress\AI\Abilities\Utilities\Prompts; use WordPress\AI\Settings\Settings_Page; use WordPress\AI\Settings\Settings_Registration; use WordPress\AI_Client\AI_Client; @@ -224,10 +223,6 @@ function initialize_experiments(): void { $post_abilities = new Posts(); $post_abilities->register(); - // Register our prompt-related WordPress Abilities. - $prompt_abilities = new Prompts(); - $prompt_abilities->register(); - add_action( 'wp_abilities_api_categories_init', static function () { From 03e8580ddda4d442900114459284d489ae7f35f8 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Jan 2026 09:49:33 -0700 Subject: [PATCH 28/54] Add the system instructions for the prompt generation. Update the ability with the proper methods to generate a prompt --- .../Abilities/Image/Generate_Image_Prompt.php | 211 ++++++++++++++++++ includes/Abilities/Image/Generate_Prompt.php | 139 ------------ .../Image/image-prompt-system-instruction.php | 35 +++ .../Image_Generation/Image_Generation.php | 37 +-- 4 files changed, 253 insertions(+), 169 deletions(-) create mode 100644 includes/Abilities/Image/Generate_Image_Prompt.php delete mode 100644 includes/Abilities/Image/Generate_Prompt.php create mode 100644 includes/Abilities/Image/image-prompt-system-instruction.php diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php new file mode 100644 index 00000000..defcfc53 --- /dev/null +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -0,0 +1,211 @@ + '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 ) { + // Ensure the user is logged in. + return is_user_logged_in(); + } + + /** + * {@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 array|string $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() ) + ->generate_text(); + } +} diff --git a/includes/Abilities/Image/Generate_Prompt.php b/includes/Abilities/Image/Generate_Prompt.php deleted file mode 100644 index d635f81f..00000000 --- a/includes/Abilities/Image/Generate_Prompt.php +++ /dev/null @@ -1,139 +0,0 @@ - 'object', - 'properties' => array( - 'purpose' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => esc_html__( 'The intended purpose for the prompt.', 'ai' ), - ), - 'context' => array( - 'type' => 'string', - 'sanitize_callback' => 'sanitize_text_field', - 'description' => esc_html__( 'Any additional context to help generate the prompt.', 'ai' ), - ), - ), - 'required' => array( 'purpose', 'context' ), - ); - } - - /** - * {@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( - 'purpose' => '', - 'context' => '', - ), - ); - - $content = "## Purpose\n" . $args['purpose']; - - // If context is provided, add it to the content. - if ( ! empty( $args['context'] ) ) { - $content .= "\n\n## Context\n" . $args['context']; - } - - // Generate the prompt using the AI client. - return AI_Client::prompt_with_wp_error( '"""' . $content . '"""' ) - ->using_system_instruction( self::$prompt_generation_system_instruction ) - ->using_temperature( 0.9 ) - ->using_model_preference( ...get_preferred_models() ) - ->generate_text(); - } - - /** - * {@inheritDoc} - * - * @since x.x.x - */ - protected function permission_callback( $args ) { - // Ensure the user is logged in. - return is_user_logged_in(); - } - - /** - * {@inheritDoc} - * - * @since x.x.x - */ - protected function meta(): array { - return array( - 'show_in_rest' => true, - 'mcp' => array( - 'public' => true, - 'type' => 'prompt', - ), - ); - } -} 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..8dd49ec4 --- /dev/null +++ b/includes/Abilities/Image/image-prompt-system-instruction.php @@ -0,0 +1,35 @@ + __( 'Image Prompt Generation', 'ai' ), 'description' => __( 'Generates a prompt from post content that can be used to generate an image', 'ai' ), - 'ability_class' => Generate_Prompt_Ability::class, + 'ability_class' => Generate_Image_Prompt_Ability::class, ), ); } @@ -149,12 +127,11 @@ public function enqueue_assets( string $hook_suffix ): void { 'image_generation', 'ImageGenerationData', array( - 'enabled' => $this->is_enabled(), - 'generatePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', - 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', - 'getContextPath' => 'wp-abilities/v1/abilities/ai/get-post-details/run', - 'generatePromptPath' => 'wp-abilities/v1/abilities/ai/generate-prompt/run', - 'generatePromptPurpose' => self::$prompt_generation_purpose, + 'enabled' => $this->is_enabled(), + 'generatePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', + 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', + 'getContextPath' => 'wp-abilities/v1/abilities/ai/get-post-details/run', + 'generatePromptPath' => 'wp-abilities/v1/abilities/ai/generate-prompt/run', ) ); } From a5cb760a945067e1d28d36c69a9106efbea30d76 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Jan 2026 10:29:33 -0700 Subject: [PATCH 29/54] In our content normalization function, remove extra linebreaks so we keep the amount of content we send to a minimum --- includes/helpers.php | 3 +++ tests/Integration/Includes/HelpersTest.php | 27 ++++++++++++++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/includes/helpers.php b/includes/helpers.php index 414b4aa6..fdb31e45 100644 --- a/includes/helpers.php +++ b/includes/helpers.php @@ -38,6 +38,9 @@ function normalize_content( string $content ): string { // Replace HTML linebreaks with newlines. $content = preg_replace( '##', "\n\n", (string) $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 ); diff --git a/tests/Integration/Includes/HelpersTest.php b/tests/Integration/Includes/HelpersTest.php index 1ca96181..0b45ed64 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' ); } /** From 6b31fae573da488bf42141f7afaf8f34da9bf7ff Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Jan 2026 10:30:55 -0700 Subject: [PATCH 30/54] Update our UI components to take advantage of the new prompt generation ability --- .../Image_Generation/Image_Generation.php | 6 +++--- .../image-generation/functions/generate-image.ts | 8 ++------ .../image-generation/functions/generate-prompt.ts | 10 +++++++--- .../image-generation/functions/strip-html.ts | 14 -------------- 4 files changed, 12 insertions(+), 26 deletions(-) delete mode 100644 src/experiments/image-generation/functions/strip-html.ts diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index c16695d5..2ebd42c2 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -33,7 +33,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 passed in prompt', 'ai' ), ); } @@ -128,10 +128,10 @@ public function enqueue_assets( string $hook_suffix ): void { 'ImageGenerationData', array( 'enabled' => $this->is_enabled(), - 'generatePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', + 'generateImagePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', 'getContextPath' => 'wp-abilities/v1/abilities/ai/get-post-details/run', - 'generatePromptPath' => 'wp-abilities/v1/abilities/ai/generate-prompt/run', + 'generatePromptPath' => 'wp-abilities/v1/abilities/ai/image-prompt-generation/run', ) ); } diff --git a/src/experiments/image-generation/functions/generate-image.ts b/src/experiments/image-generation/functions/generate-image.ts index 376b3c7b..555d3ffe 100644 --- a/src/experiments/image-generation/functions/generate-image.ts +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -9,7 +9,6 @@ import apiFetch from '@wordpress/api-fetch'; import { formatContext } from './format-context'; import { getContext } from './get-context'; import { generatePrompt } from './generate-prompt'; -import { stripHTML } from './strip-html'; const { aiImageGenerationData } = window as any; @@ -42,13 +41,10 @@ export async function generateImage( ); } - // Remove HTML from the content to limit the amount of tokens we use. - context.content = stripHTML( content ); - let prompt: string; try { - prompt = await generatePrompt( formatContext( context ) ); + prompt = await generatePrompt( content, formatContext( context ) ); } catch ( error: any ) { throw new Error( `Failed to generate prompt: ${ error.message || error }` @@ -56,7 +52,7 @@ export async function generateImage( } return apiFetch( { - path: aiImageGenerationData?.generatePath ?? '', + path: aiImageGenerationData?.generateImagePath ?? '', method: 'POST', data: { input: { diff --git a/src/experiments/image-generation/functions/generate-prompt.ts b/src/experiments/image-generation/functions/generate-prompt.ts index 634be729..a4e8a72b 100644 --- a/src/experiments/image-generation/functions/generate-prompt.ts +++ b/src/experiments/image-generation/functions/generate-prompt.ts @@ -8,16 +8,20 @@ const { aiImageGenerationData } = window as any; /** * Generates a featured image generation prompt for the given post ID and content. * - * @param {string} context The context to generate a featured image prompt for. + * @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( context: string ): Promise< string > { +export async function generatePrompt( + content: string, + context: string +): Promise< string > { return apiFetch( { path: aiImageGenerationData?.generatePromptPath ?? '', method: 'POST', data: { input: { - purpose: aiImageGenerationData?.generatePromptPurpose, + content, context, }, }, diff --git a/src/experiments/image-generation/functions/strip-html.ts b/src/experiments/image-generation/functions/strip-html.ts deleted file mode 100644 index 85b2d9a5..00000000 --- a/src/experiments/image-generation/functions/strip-html.ts +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Strips HTML tags from a string, preserving text content. - * - * Also removes linebreaks from the text content. - * - * @param {string} html The HTML string to strip. - * @return {string} The text content without HTML tags. - */ -export function stripHTML( html: string ): string { - const tempDiv = document.createElement( 'div' ); - tempDiv.innerHTML = html; - const textContent = tempDiv.textContent || tempDiv.innerText || ''; - return textContent.replace( /\n/g, '' ); -} From 3c0db53683ebd91631ae41e18c80f71f97e5b39b Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Jan 2026 10:37:00 -0700 Subject: [PATCH 31/54] Update docs --- docs/experiments/image-generation.md | 240 +++++++++++++++++++++------ 1 file changed, 190 insertions(+), 50 deletions(-) diff --git a/docs/experiments/image-generation.md b/docs/experiments/image-generation.md index ca2794e4..fa53a712 100644 --- a/docs/experiments/image-generation.md +++ b/docs/experiments/image-generation.md @@ -21,15 +21,14 @@ When enabled, the Image Generation experiment adds a "Generate featured image" b ### For Developers -The experiment consists of three main components: +The experiment consists of four main components: 1. **Experiment Class** (`WordPress\AI\Experiments\Image_Generation\Image_Generation`): Handles registration, asset enqueuing, UI integration, and post meta registration -2. **Generate Image Ability** (`WordPress\AI\Abilities\Image\Generate_Image`): Generates base64-encoded images from prompts using AI models -3. **Import Image Ability** (`WordPress\AI\Abilities\Image\Import_Base64_Image`): Imports base64-encoded images into the WordPress media library +2. **Generate Image Prompt Ability** (`WordPress\AI\Abilities\Image\Generate_Image_Prompt`): Generates optimized image generation prompts from post content and context +3. **Generate Image Ability** (`WordPress\AI\Abilities\Image\Generate_Image`): Generates base64-encoded images from prompts using AI models +4. **Import Image Ability** (`WordPress\AI\Abilities\Image\Import_Base64_Image`): Imports base64-encoded images into the WordPress media library -The experiment also leverages the `ai/generate-prompt` ability (from `WordPress\AI\Abilities\Utilities\Prompts`) to create optimized image generation prompts from post context. - -Both abilities can be called directly via REST API, making them useful for automation, bulk processing, or custom integrations. +All three abilities can be called directly via REST API, making them useful for automation, bulk processing, or custom integrations. ## Architecture & Implementation @@ -37,7 +36,7 @@ Both abilities can be called directly via REST API, making them useful for autom - `WordPress\AI\Experiments\Image_Generation\Image_Generation::register()` wires everything once the experiment is enabled: - `register_post_meta()` → registers `ai_generated` post meta for attachment post type - - `wp_abilities_api_init` → registers the `ai/image-generation` and `ai/image-import` abilities + - `wp_abilities_api_init` → registers the `ai/image-generation`, `ai/image-import`, and `ai/image-prompt-generation` abilities - `admin_enqueue_scripts` → enqueues the React bundle on `post.php` and `post-new.php` screens for post types that support featured images ### Assets & Data Flow @@ -45,21 +44,20 @@ Both abilities can be called directly via REST API, making them useful for autom 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 - - `generatePath`: REST API path to image generation ability (`/wp-json/wp-abilities/v1/abilities/ai/image-generation/run`) - - `importPath`: REST API path to image import ability (`/wp-json/wp-abilities/v1/abilities/ai/image-import/run`) - - `getContextPath`: REST API path to get post details (`/wp-json/wp-abilities/v1/abilities/ai/get-post-details/run`) - - `generatePromptPath`: REST API path to prompt generation ability (`/wp-json/wp-abilities/v1/abilities/ai/generate-prompt/run`) - - `generatePromptPurpose`: System instruction for generating image prompts + - `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`) 2. **React Side:** - - The React entry point (`index.ts`) imports `featured-image.tsx` which hooks into the featured image panel using the `editor.PostFeaturedImage` filter + - The React entry point (`featured-image.tsx`) hooks into the featured image panel using the `editor.PostFeaturedImage` filter - `GenerateFeaturedImage` component renders a button that: - Gets current post ID and content from the editor store - Calls `generateImage()` function which: - Gets post context (title, type) via `getContext()` - - Strips HTML from content - - Formats context and calls `generatePrompt()` to create an image generation prompt - - Calls the image generation ability with the prompt + - 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 @@ -70,13 +68,16 @@ Both abilities can be called directly via REST API, making them useful for autom - `AILabel` component displays a label for AI-generated images by checking the `ai_generated` meta 3. **Ability Execution Flow:** - - **Prompt Generation** (via `ai/generate-prompt`): - - Accepts `purpose` (system instruction) and `context` (formatted post data) - - Uses AI to generate an optimized image generation prompt - - Returns a plain text prompt string + - **Image Prompt Generation** (via `ai/image-prompt-generation`): + - Accepts `content` (string), `context` (string or post ID), and optional `style` (string) as input + - If `context` is numeric, treats it as a post ID and fetches post context using `get_post_context()` + - Normalizes content using `normalize_content()` helper + - Uses AI with a dedicated system instruction to generate an optimized image generation prompt + - Returns a plain text prompt string suitable for image generation models - **Image Generation** (via `ai/image-generation`): - Accepts `prompt` (string) as input - Uses AI image generation models (via `get_preferred_image_models()`) + - Sets request timeout to 90 seconds for longer generation times - Returns base64-encoded image data - **Image Import** (via `ai/image-import`): - Accepts base64 image data and metadata (filename, title, description, alt_text, mime_type, meta) @@ -87,6 +88,32 @@ Both abilities can be called directly via REST API, making them useful for autom ### Input Schemas +#### Image Prompt Generation Ability + +```php +array( + 'type' => 'object', + 'properties' => array( + 'content' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'The content to use as inspiration for the generated image.', + ), + 'context' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => '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).', + ), + 'style' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => 'Any additional style instructions to apply to the generated image.', + ), + ), + 'required' => array( 'content' ), +) +``` + #### Image Generation Ability ```php @@ -167,7 +194,18 @@ array( ### Output Schemas -#### Image Generation Ability +#### Image Prompt Generation Ability Output + +The ability returns a plain text string containing the generated image prompt: + +```php +array( + 'type' => 'string', + 'description' => 'The image generation prompt.', +) +``` + +#### Image Generation Ability Output The ability returns a plain text string containing base64-encoded image data: @@ -178,7 +216,7 @@ array( ) ``` -#### Image Import Ability +#### Image Import Ability Output The ability returns an object with image data: @@ -222,18 +260,20 @@ array( ### Permissions -Both abilities check permissions: +All abilities check permissions: +- **Image Prompt Generation:** Requires user to be logged in (`is_user_logged_in()`) - **Image Generation:** Requires `current_user_can( 'upload_files' )` - **Image Import:** Requires `current_user_can( 'upload_files' )` ## Using the Abilities via REST API -Both the image generation and image import abilities can be called directly via REST API, making them useful for automation, bulk processing, or custom integrations. +All three abilities can be called directly via REST API, making them useful for automation, bulk processing, or custom integrations. ### Endpoints ```text +POST /wp-json/wp-abilities/v1/abilities/ai/image-prompt-generation/run POST /wp-json/wp-abilities/v1/abilities/ai/image-generation/run POST /wp-json/wp-abilities/v1/abilities/ai/image-import/run ``` @@ -249,7 +289,28 @@ See [TESTING_REST_API.md](../TESTING_REST_API.md) for detailed authentication in ### Request Examples -#### Example 1: Generate Image from Prompt +#### Example 1: Generate Image Prompt from Content + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-prompt-generation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "content": "This article discusses the benefits of renewable energy and solar power installations.", + "context": "Title: Renewable Energy Solutions\nType: post", + "style": "Editorial style, professional photography" + } + }' +``` + +**Response:** + +```json +"A professional editorial photograph of a modern solar panel installation in a sunny landscape, showcasing renewable energy technology with clean, bright lighting and a professional composition" +``` + +#### Example 2: Generate Image from Prompt ```bash curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-generation/run" \ @@ -270,7 +331,22 @@ curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-ge The response is a base64-encoded string of the image data. -#### Example 2: Import Base64 Image into Media Library +#### Example 3: Generate Image Prompt from Post ID + +```bash +curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-prompt-generation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d '{ + "input": { + "context": "123" + } + }' +``` + +This will automatically fetch the content from post ID 123 and generate an image prompt. + +#### Example 4: Import Base64 Image into Media Library ```bash curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-import/run" \ @@ -309,20 +385,31 @@ curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-im } ``` -#### Example 3: Complete Flow - Generate and Import Image +#### Example 5: Complete Flow - Generate Prompt, Generate Image, and Import ```bash -# Step 1: Generate the image -GENERATED_IMAGE=$(curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-generation/run" \ +# Step 1: Generate the image prompt +PROMPT=$(curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-prompt-generation/run" \ -u "username:application-password" \ -H "Content-Type: application/json" \ -d '{ "input": { - "prompt": "A modern office workspace with plants and natural lighting" + "content": "This article discusses modern office design trends and workspace productivity.", + "context": "Title: Modern Office Design\nType: post" } }') -# Step 2: Import the image +# Step 2: Generate the image using the prompt +GENERATED_IMAGE=$(curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-generation/run" \ + -u "username:application-password" \ + -H "Content-Type: application/json" \ + -d "{ + \"input\": { + \"prompt\": \"$PROMPT\" + } + }") + +# Step 3: Import the image curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-import/run" \ -u "username:application-password" \ -H "Content-Type: application/json" \ @@ -344,11 +431,34 @@ curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/image-im }" ``` -#### Example 4: Using JavaScript (Fetch API) +#### Example 6: Using JavaScript (Fetch API) ```javascript -async function generateAndImportImage(prompt, filename, title) { - // Step 1: Generate image +async function generateAndImportImage(content, context, filename, title) { + // Step 1: Generate image prompt + const promptResponse = await fetch( + '/wp-json/wp-abilities/v1/abilities/ai/image-prompt-generation/run', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': wpApiSettings.nonce, + }, + credentials: 'include', + body: JSON.stringify({ + input: { content, context }, + }), + } + ); + + if (!promptResponse.ok) { + const error = await promptResponse.json(); + throw new Error(error.message || 'Failed to generate prompt'); + } + + const prompt = await promptResponse.text(); + + // Step 2: Generate image const generateResponse = await fetch( '/wp-json/wp-abilities/v1/abilities/ai/image-generation/run', { @@ -371,7 +481,7 @@ async function generateAndImportImage(prompt, filename, title) { const base64Image = await generateResponse.text(); - // Step 2: Import image + // Step 3: Import image const importResponse = await fetch( '/wp-json/wp-abilities/v1/abilities/ai/image-import/run', { @@ -411,7 +521,8 @@ async function generateAndImportImage(prompt, filename, title) { // Usage generateAndImportImage( - 'A futuristic cityscape at night', + 'This article discusses futuristic urban planning and smart cities.', + 'Title: Future Cities\nType: post', 'futuristic-city', 'Futuristic Cityscape' ) @@ -419,14 +530,23 @@ generateAndImportImage( .catch(error => console.error('Error:', error)); ``` -#### Example 5: Using WordPress API Fetch (in Gutenberg/Admin) +#### Example 7: Using WordPress API Fetch (in Gutenberg/Admin) ```javascript import apiFetch from '@wordpress/api-fetch'; -async function generateAndImportImage(prompt, filename, title) { +async function generateAndImportImage(content, context, filename, title) { try { - // Step 1: Generate image + // Step 1: Generate image prompt + const prompt = await apiFetch({ + path: '/wp-abilities/v1/abilities/ai/image-prompt-generation/run', + method: 'POST', + data: { + input: { content, context }, + }, + }); + + // Step 2: Generate image const base64Image = await apiFetch({ path: '/wp-abilities/v1/abilities/ai/image-generation/run', method: 'POST', @@ -435,7 +555,7 @@ async function generateAndImportImage(prompt, filename, title) { }, }); - // Step 2: Import image + // Step 3: Import image const result = await apiFetch({ path: '/wp-abilities/v1/abilities/ai/image-import/run', method: 'POST', @@ -469,6 +589,12 @@ async function generateAndImportImage(prompt, filename, title) { The abilities may return the following error codes: +**Image Prompt Generation:** + +- `post_not_found`: The provided post ID does not exist +- `content_not_provided`: No content was provided and no valid post ID was found +- `no_results`: The AI client did not return any results + **Image Generation:** - `no_results`: The AI client did not return any results @@ -498,15 +624,15 @@ Example error response: ## Extending the Experiment -### Customizing the Prompt Generation Purpose +### Customizing the Image Prompt Generation System Instruction -The system instruction that guides prompt generation can be customized by modifying the `$prompt_generation_purpose` property in: +The system instruction that guides image prompt generation can be customized by modifying: ```php -includes/Experiments/Image_Generation/Image_Generation.php +includes/Abilities/Image/image-prompt-system-instruction.php ``` -This instruction tells the AI how to create image generation prompts from post context. You can modify it to change the style, tone, or requirements for generated prompts. +This instruction tells the AI how to create image generation prompts from post content and context. You can modify it to change the style, tone, or requirements for generated prompts. The system instruction is specifically designed for image generation prompts and differs from generic prompt generation. ### Filtering Preferred Image Models @@ -541,6 +667,12 @@ The experiment uses `getContext()` to fetch post details (title, type). You can src/experiments/image-generation/functions/get-context.ts ``` +The context is formatted using `formatContext()` which converts key-value pairs into a string format. You can customize this formatting by modifying: + +```typescript +src/experiments/image-generation/functions/format-context.ts +``` + ### Adding Custom UI Elements You can extend the React components to add custom UI elements: @@ -651,10 +783,17 @@ npm run test:php ### Prompt Generation -- The experiment uses a two-step process: - 1. First, it generates an optimized image generation prompt from post context using the `ai/generate-prompt` ability - 2. Then, it uses that prompt to generate the actual image -- The prompt generation purpose is defined in the experiment class and instructs the AI on how to create image prompts +- The experiment uses a three-step process: + 1. First, it gets post context (title, type) using the `ai/get-post-details` ability + 2. Then, it generates an optimized image generation prompt from post content and context using the `ai/image-prompt-generation` ability + 3. Finally, it uses that prompt to generate the actual image +- The image prompt generation uses a dedicated system instruction (`image-prompt-system-instruction.php`) that is specifically designed for creating image generation prompts +- The system instruction ensures the generated prompt: + - Is self-contained and can be passed directly to image generation models + - Incorporates content and context faithfully + - Describes the subject, setting, and visual style clearly + - Avoids text, captions, logos, or branding unless specified + - Reflects the content's theme without being overly literal - The generated prompt is designed to be suitable for image generation models and reflects the article's core topic and tone ### Image Metadata @@ -684,10 +823,11 @@ npm run test:php ## 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` -- **Prompt Generation Ability:** `includes/Abilities/Utilities/Prompts.php` -- **React Entry:** `src/experiments/image-generation/index.ts` +- **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` From 8ff538b26819bea9a33692a09c34126214e2d3ca Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Jan 2026 10:39:35 -0700 Subject: [PATCH 32/54] Update tests --- .../Abilities/Image_Prompt_GenerationTest.php | 483 ++++++++++++++++++ .../Includes/Abilities/PromptsTest.php | 443 ---------------- .../Image_Generation/Image_GenerationTest.php | 49 ++ 3 files changed, 532 insertions(+), 443 deletions(-) create mode 100644 tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php delete mode 100644 tests/Integration/Includes/Abilities/PromptsTest.php 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..05704224 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php @@ -0,0 +1,483 @@ + '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 logged in user. + * + * @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 ); + + // Create a logged in user. + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $method->invoke( $this->ability, array() ); + + $this->assertTrue( $result, 'Permission should be granted for logged in user' ); + } + + /** + * Test that permission_callback() returns false 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->assertFalse( $result, 'Permission should be denied for logged out user' ); + } + + /** + * 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/Abilities/PromptsTest.php b/tests/Integration/Includes/Abilities/PromptsTest.php deleted file mode 100644 index 642715fc..00000000 --- a/tests/Integration/Includes/Abilities/PromptsTest.php +++ /dev/null @@ -1,443 +0,0 @@ -prompts = new Prompts(); - - // Register the ability category if it doesn't exist. - if ( function_exists( 'wp_register_ability_category' ) ) { - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound - if ( ! function_exists( 'wp_get_ability_category' ) || ! \wp_get_ability_category( AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY ) ) { - // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedFunctionFound - \wp_register_ability_category( - AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY, - array( - 'label' => __( 'AI Experiments', 'ai' ), - 'description' => __( 'Various AI experiments.', 'ai' ), - ) - ); - } - } - } - - /** - * Tear down test case. - * - * @since x.x.x - */ - public function tearDown(): void { - wp_set_current_user( 0 ); - parent::tearDown(); - } - - /** - * Test that register() hooks into wp_abilities_api_init. - * - * @since x.x.x - */ - public function test_register_hooks_into_wp_abilities_api_init() { - // Clear any existing hooks. - remove_all_actions( 'wp_abilities_api_init' ); - - $this->prompts->register(); - - // Verify the hook was added. has_action returns priority (int) or false. - $has_action = has_action( 'wp_abilities_api_init', array( $this->prompts, 'register_abilities' ) ); - $this->assertNotFalse( - $has_action, - 'register() should hook register_abilities into wp_abilities_api_init' - ); - } - - /** - * Test that register_abilities() registers the generate-prompt ability. - * - * @since x.x.x - */ - public function test_register_abilities_registers_generate_prompt_ability() { - 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' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - $this->assertNotNull( $ability, 'Ability should be registered' ); - $this->assertInstanceOf( \WP_Ability::class, $ability, 'Should be a WP_Ability instance' ); - } - - /** - * Test that the ability has correct label and description. - * - * @since x.x.x - */ - public function test_ability_has_correct_label_and_description() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - $this->assertEquals( - 'Generate a prompt', - $ability->get_label(), - 'Label should match' - ); - $this->assertEquals( - 'Generate a prompt for a specific purpose.', - $ability->get_description(), - 'Description should match' - ); - } - - /** - * Test that the ability has correct category. - * - * @since x.x.x - */ - public function test_ability_has_correct_category() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - // Use reflection to access the category property. - $reflection = new \ReflectionClass( $ability ); - $property = $reflection->getProperty( 'category' ); - $property->setAccessible( true ); - - $this->assertEquals( - AI_EXPERIMENTS_DEFAULT_ABILITY_CATEGORY, - $property->getValue( $ability ), - 'Category should match' - ); - } - - /** - * Test that input_schema has correct structure. - * - * @since x.x.x - */ - public function test_input_schema_has_correct_structure() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - $schema = $ability->get_input_schema(); - - $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( 'purpose', $schema['properties'], 'Schema should have purpose property' ); - $this->assertArrayHasKey( 'context', $schema['properties'], 'Schema should have context property' ); - $this->assertArrayHasKey( 'required', $schema, 'Schema should have required array' ); - $this->assertContains( 'purpose', $schema['required'], 'purpose should be required' ); - $this->assertContains( 'context', $schema['required'], 'context should be required' ); - - // Verify purpose property. - $this->assertEquals( 'string', $schema['properties']['purpose']['type'], 'Purpose should be string type' ); - $this->assertEquals( 'sanitize_text_field', $schema['properties']['purpose']['sanitize_callback'], 'Purpose 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' ); - } - - /** - * Test that output_schema has correct structure. - * - * @since x.x.x - */ - public function test_output_schema_has_correct_structure() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - $schema = $ability->get_output_schema(); - - $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 generated prompt.', $schema['description'], 'Description should match' ); - } - - /** - * Test that execute_callback formats content correctly with purpose and context. - * - * @since x.x.x - */ - public function test_execute_callback_formats_content_with_purpose_and_context() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - $input = array( - 'purpose' => 'Generate a featured image', - 'context' => 'Title: Test Post\nType: post', - ); - - try { - $result = $ability->execute( $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 formats content correctly with purpose only. - * - * @since x.x.x - */ - public function test_execute_callback_formats_content_with_purpose_only() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - $input = array( - 'purpose' => 'Generate a featured image', - 'context' => '', - ); - - try { - $result = $ability->execute( $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 uses default values for missing parameters. - * - * @since x.x.x - */ - public function test_execute_callback_uses_default_values() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - $input = array(); - - try { - $result = $ability->execute( $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; - } - - // Should still work with empty defaults. - $this->assertIsString( $result, 'Result should be a string' ); - } - - /** - * Test that permission_callback returns true for logged in user. - * - * @since x.x.x - */ - public function test_permission_callback_returns_true_for_logged_in_user() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - // Create a logged in user. - $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); - wp_set_current_user( $user_id ); - - // Use reflection to access the permission_callback. - $reflection = new \ReflectionClass( $ability ); - $property = $reflection->getProperty( 'permission_callback' ); - $property->setAccessible( true ); - $callback = $property->getValue( $ability ); - - $result = call_user_func( $callback, array() ); - - $this->assertTrue( $result, 'Permission should be granted for logged in user' ); - } - - /** - * Test that permission_callback returns false for logged out user. - * - * @since x.x.x - */ - public function test_permission_callback_returns_false_for_logged_out_user() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - // Ensure no user is logged in. - wp_set_current_user( 0 ); - - // Use reflection to access the permission_callback. - $reflection = new \ReflectionClass( $ability ); - $property = $reflection->getProperty( 'permission_callback' ); - $property->setAccessible( true ); - $callback = $property->getValue( $ability ); - - $result = call_user_func( $callback, array() ); - - $this->assertFalse( $result, 'Permission should be denied for logged out user' ); - } - - /** - * Test that meta has correct structure. - * - * @since x.x.x - */ - public function test_meta_has_correct_structure() { - 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' ); - - do_action( 'wp_abilities_api_init' ); - - $ability = wp_get_ability( 'ai/generate-prompt' ); - - // Use reflection to access the meta property. - $reflection = new \ReflectionClass( $ability ); - $property = $reflection->getProperty( 'meta' ); - $property->setAccessible( true ); - $meta = $property->getValue( $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 3c9b97f7..55a07f8e 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' ); + } } From 76d37bc98124fbf15d3434429d27c1c93c734763 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 8 Jan 2026 10:59:38 -0700 Subject: [PATCH 33/54] Fix PHPCS, PHPStan and Plugin Check errors --- includes/Abilities/Image/Generate_Image_Prompt.php | 6 ++++-- .../Abilities/Image/image-prompt-system-instruction.php | 4 ++++ includes/Experiments/Image_Generation/Image_Generation.php | 6 +++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php index defcfc53..90ba651e 100644 --- a/includes/Abilities/Image/Generate_Image_Prompt.php +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -49,7 +49,7 @@ protected function input_schema(): array { 'description' => esc_html__( 'Any additional style instructions to apply to the generated image.', 'ai' ), ), ), - 'required' => array( 'content' ), + 'required' => array( 'content' ), ); } @@ -139,6 +139,8 @@ protected function execute_callback( $input ) { * {@inheritDoc} * * @since x.x.x + * + * @return bool True if the user is logged in, false otherwise. */ protected function permission_callback( $args ) { // Ensure the user is logged in. @@ -166,7 +168,7 @@ protected function meta(): array { * @since x.x.x * * @param string $content The content to use as inspiration for the final generated image. - * @param array|string $context The context to help generate the prompt. + * @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. */ diff --git a/includes/Abilities/Image/image-prompt-system-instruction.php b/includes/Abilities/Image/image-prompt-system-instruction.php index 8dd49ec4..b65cb85c 100644 --- a/includes/Abilities/Image/image-prompt-system-instruction.php +++ b/includes/Abilities/Image/image-prompt-system-instruction.php @@ -5,6 +5,10 @@ * @package WordPress\AI\Abilities\Image */ +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + // phpcs:ignore Squiz.PHP.Heredoc.NotAllowed return <<<'INSTRUCTION' You are a helpful assistant that generates a single, self-contained image generation prompt suitable for use with an image generation LLM. diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index 2ebd42c2..dcfbb0be 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -10,11 +10,15 @@ namespace WordPress\AI\Experiments\Image_Generation; use WordPress\AI\Abilities\Image\Generate_Image as Image_Generation_Ability; -use WordPress\AI\Abilities\Image\Import_Base64_Image as Image_Import_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; +if ( ! defined( 'ABSPATH' ) ) { + exit; +} + /** * Image generation experiment. * From da5f8cedc4527431e8227bfad7311ce8fd4a8ff7 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 13 Jan 2026 09:33:57 -0700 Subject: [PATCH 34/54] Ensure missing dependency is in package.json --- package-lock.json | 4 +--- package.json | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 55018b7d..53ce99d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@wordpress/block-editor": "^15.7.0", "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", + "@wordpress/core-data": "^7.37.0", "@wordpress/data": "^10.34.0", "@wordpress/editor": "^14.34.0", "@wordpress/element": "^6.34.0", @@ -22016,7 +22017,6 @@ "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.57.0" }, @@ -22036,7 +22036,6 @@ "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -22055,7 +22054,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } diff --git a/package.json b/package.json index 4d294b58..6aa354be 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@wordpress/block-editor": "^15.7.0", "@wordpress/components": "^30.7.0", "@wordpress/compose": "^7.34.0", + "@wordpress/core-data": "^7.37.0", "@wordpress/data": "^10.34.0", "@wordpress/editor": "^14.34.0", "@wordpress/element": "^6.34.0", From 21fb91cd2be734c722b27be6587a1c2f2608ac97 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 14 Jan 2026 13:55:09 -0700 Subject: [PATCH 35/54] Fix typescript errors --- .../image-generation/components/AILabel.tsx | 5 ----- .../components/GenerateFeaturedImage.tsx | 10 ++++------ 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/src/experiments/image-generation/components/AILabel.tsx b/src/experiments/image-generation/components/AILabel.tsx index 12f9a1a1..58631c5e 100644 --- a/src/experiments/image-generation/components/AILabel.tsx +++ b/src/experiments/image-generation/components/AILabel.tsx @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import React from 'react'; - /** * WordPress dependencies */ diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx index 9ce62c0b..8f4fe784 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import React from 'react'; - /** * WordPress dependencies */ @@ -50,7 +45,10 @@ export default function GenerateFeaturedImage(): JSX.Element { ); try { - const generatedImage = await generateImage( postId, content ); + const generatedImage = await generateImage( + postId as number, + content + ); const importedImage = await uploadImage( generatedImage ); editPost( { featured_media: importedImage.id, From 8aeca86c79b5824d9615743962118b9b49a97c29 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 26 Jan 2026 11:47:22 -0700 Subject: [PATCH 36/54] Use new function name --- includes/Abilities/Image/Generate_Image_Prompt.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php index 90ba651e..b7cddd90 100644 --- a/includes/Abilities/Image/Generate_Image_Prompt.php +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -14,7 +14,7 @@ use WordPress\AI_Client\AI_Client; use function WordPress\AI\get_post_context; -use function WordPress\AI\get_preferred_models; +use function WordPress\AI\get_preferred_models_for_text_generation; use function WordPress\AI\normalize_content; /** @@ -207,7 +207,7 @@ static function ( $key, $value ) { 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() ) + ->using_model_preference( ...get_preferred_models_for_text_generation() ) ->generate_text(); } } From e2b7403fc9daa3141d8a356df919c45718c54176 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 4 Feb 2026 13:58:37 -0700 Subject: [PATCH 37/54] Add better capability checks on the image prompt generation Ability --- .../Abilities/Image/Generate_Image_Prompt.php | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php index b7cddd90..1218f746 100644 --- a/includes/Abilities/Image/Generate_Image_Prompt.php +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -143,8 +143,49 @@ protected function execute_callback( $input ) { * @return bool True if the user is logged in, false otherwise. */ protected function permission_callback( $args ) { - // Ensure the user is logged in. - return is_user_logged_in(); + $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; } /** From b79aefbdb362bac50c15104342ba72ba2a2d349a Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 4 Feb 2026 13:59:09 -0700 Subject: [PATCH 38/54] Remove duplicate line; update system instructions a bit; update Experiment description --- .../Abilities/Image/image-prompt-system-instruction.php | 2 +- includes/Experiments/Image_Generation/Image_Generation.php | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/includes/Abilities/Image/image-prompt-system-instruction.php b/includes/Abilities/Image/image-prompt-system-instruction.php index b65cb85c..59c984ec 100644 --- a/includes/Abilities/Image/image-prompt-system-instruction.php +++ b/includes/Abilities/Image/image-prompt-system-instruction.php @@ -15,7 +15,7 @@ You will be given: - Some content to use as inspiration for the final generated image -- Additional context, provided in a structured, line-by-line key-value format +- Additional context, provided in a structured format - Some optional style instructions to apply to the final generated image Your task is to synthesize this information into a single, complete image generation prompt that can be passed directly to another LLM to immediately generate an image. diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index 353d39ac..707be423 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -19,10 +19,6 @@ exit; } -if ( ! defined( 'ABSPATH' ) ) { - exit; -} - /** * Image generation experiment. * @@ -41,7 +37,7 @@ protected function load_experiment_metadata(): array { return array( 'id' => 'image-generation', 'label' => __( 'Image Generation', 'ai' ), - 'description' => __( 'Generates a featured image from a passed in prompt', 'ai' ), + 'description' => __( 'Generates a featured image from a generated image prompt', 'ai' ), ); } From c7f2d5c5412b6edc36326adee4459d7967f409e8 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 4 Feb 2026 14:04:14 -0700 Subject: [PATCH 39/54] Fix PHPStan error --- includes/Abilities/Image/Generate_Image_Prompt.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image_Prompt.php b/includes/Abilities/Image/Generate_Image_Prompt.php index 1218f746..f4e0b90e 100644 --- a/includes/Abilities/Image/Generate_Image_Prompt.php +++ b/includes/Abilities/Image/Generate_Image_Prompt.php @@ -139,8 +139,6 @@ protected function execute_callback( $input ) { * {@inheritDoc} * * @since x.x.x - * - * @return bool True if the user is logged in, false otherwise. */ protected function permission_callback( $args ) { $post_id = isset( $args['context'] ) && is_numeric( $args['context'] ) ? absint( $args['context'] ) : null; From 5c41311f38eb32a996089f0b366760d0f602c83c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 4 Feb 2026 14:09:13 -0700 Subject: [PATCH 40/54] Fix unit tests --- .../Abilities/Image_Prompt_GenerationTest.php | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php b/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php index 05704224..49c05e84 100644 --- a/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php +++ b/tests/Integration/Includes/Abilities/Image_Prompt_GenerationTest.php @@ -422,7 +422,7 @@ public function test_execute_callback_content_overrides_post_content() { } /** - * Test that permission_callback() returns true for logged in user. + * Test that permission_callback() returns true for user with edit_posts capability. * * @since x.x.x */ @@ -431,17 +431,17 @@ public function test_permission_callback_returns_true_for_logged_in_user() { $method = $reflection->getMethod( 'permission_callback' ); $method->setAccessible( true ); - // Create a logged in user. - $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + // 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 logged in user' ); + $this->assertTrue( $result, 'Permission should be granted for user with edit_posts capability' ); } /** - * Test that permission_callback() returns false for logged out user. + * Test that permission_callback() returns WP_Error for logged out user. * * @since x.x.x */ @@ -455,7 +455,28 @@ public function test_permission_callback_returns_false_for_logged_out_user() { $result = $method->invoke( $this->ability, array() ); - $this->assertFalse( $result, 'Permission should be denied for logged out user' ); + $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' ); } /** From 235fb9640047e06e4ea149bd365598c0741d5b9b Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 5 Feb 2026 15:26:46 -0700 Subject: [PATCH 41/54] Modify the generate image Ability so it will return not only the generated image data but also the provider and model metadata --- includes/Abilities/Image/Generate_Image.php | 103 ++++++++++++++++---- 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/includes/Abilities/Image/Generate_Image.php b/includes/Abilities/Image/Generate_Image.php index c70f0be5..b257df1a 100644 --- a/includes/Abilities/Image/Generate_Image.php +++ b/includes/Abilities/Image/Generate_Image.php @@ -9,11 +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\Http\DTO\RequestOptions; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use function WordPress\AI\get_preferred_image_models; @@ -50,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' ), + ), + ), + ), + ), + ), + ), ); } @@ -78,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, + ); } /** @@ -115,31 +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 - $request_options = new RequestOptions(); - $request_options->setTimeout( 90 ); - // Generate the image using the AI client. - $file = AI_Client::prompt_with_wp_error( $prompt ) - ->using_request_options( $request_options ) + $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 ); } From 7ab0f226078545fcdf9584b2a4751b1fc392f297 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 5 Feb 2026 15:27:14 -0700 Subject: [PATCH 42/54] Update the image description we use to take into account the model and provider name. Ensure we are passing those correctly after generating an image --- .../components/GenerateFeaturedImage.tsx | 4 +- .../functions/generate-image.ts | 33 ++++++++++++--- .../functions/upload-image.ts | 40 ++++++++++++++++--- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx index 8f4fe784..1b182d93 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -45,11 +45,11 @@ export default function GenerateFeaturedImage(): JSX.Element { ); try { - const generatedImage = await generateImage( + const generatedImageData = await generateImage( postId as number, content ); - const importedImage = await uploadImage( generatedImage ); + const importedImage = await uploadImage( generatedImageData ); editPost( { featured_media: importedImage.id, } ); diff --git a/src/experiments/image-generation/functions/generate-image.ts b/src/experiments/image-generation/functions/generate-image.ts index 555d3ffe..2729ea3f 100644 --- a/src/experiments/image-generation/functions/generate-image.ts +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -17,12 +17,19 @@ const { aiImageGenerationData } = window as any; * * @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. - * @return {Promise} A promise that resolves to the generated image. + * @return {Promise<{ image: { data: string; provider_metadata: { id: string; name: string; type: string; }; model_metadata: { id: string; name: string; }; }; prompt: string; }>} A promise that resolves to the generated image data. */ export async function generateImage( postId: number, content: string -): Promise< string > { +): Promise< { + image: { + data: string; + provider_metadata: { id: string; name: string; type: string }; + model_metadata: { id: string; name: string }; + }; + prompt: string; +} > { let context: { title: string; type: string; @@ -61,11 +68,27 @@ export async function generateImage( }, } ) .then( ( response ) => { - if ( response && typeof response === 'string' ) { - return response; + if ( response && typeof response === 'object' ) { + const result = response as { prompt?: string }; + result.prompt = prompt; + return result as { + image: { + data: string; + provider_metadata: { + id: string; + name: string; + type: string; + }; + model_metadata: { + id: string; + name: string; + }; + }; + prompt: string; + }; } - return ''; + throw new Error( 'Invalid response from generate image' ); } ) .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 index 7a911cf2..3d573568 100644 --- a/src/experiments/image-generation/functions/upload-image.ts +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -2,17 +2,37 @@ * WordPress dependencies */ import apiFetch from '@wordpress/api-fetch'; -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; const { aiImageGenerationData } = window as any; /** * Uploads an image to the media library. * - * @param {string} image The image to upload. + * @param {Object} image The image object. + * @param {Object} image.image The image data. + * @param {string} image.image.data The base64 encoded image data. + * @param {Object} image.image.provider_metadata The provider metadata. + * @param {string} image.image.provider_metadata.id The provider ID. + * @param {string} image.image.provider_metadata.name The provider name. + * @param {string} image.image.provider_metadata.type The provider type. + * @param {Object} image.image.model_metadata The model metadata. + * @param {string} image.image.model_metadata.id The model ID. + * @param {string} image.image.model_metadata.name The model name. + * @param {string} image.prompt The prompt used to generate the image. * @return {Promise<{ id: number; url: string; title: string }>} A promise that resolves to the uploaded image data. */ -export async function uploadImage( image: string ): Promise< { +export async function uploadImage( { + image, + prompt, +}: { + image: { + data: string; + provider_metadata: { id: string; name: string; type: string }; + model_metadata: { id: string; name: string }; + }; + prompt: string; +} ): Promise< { id: number; url: string; title: string; @@ -22,10 +42,20 @@ export async function uploadImage( image: string ): Promise< { method: 'POST', data: { input: { - data: image, + data: image.data, mime_type: 'image/png', title: __( 'AI Generated Image', 'ai' ), - description: __( 'This is an AI generated image.', 'ai' ), + 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', From 3de916b36d28314730a17a6cf485a32e647b8260 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 5 Feb 2026 16:43:43 -0700 Subject: [PATCH 43/54] Move the alt text generation function into a utility file so it can be reused when needed --- .../components/AltTextControls.tsx | 88 +------------------ src/utils/generate-alt-text.ts | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+), 85 deletions(-) create mode 100644 src/utils/generate-alt-text.ts 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/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' ) ); +} From f315328982f02d393165091be736654ac458a127 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 5 Feb 2026 16:45:55 -0700 Subject: [PATCH 44/54] If alt text generation is turned on, after generating the featured image, send it off to AI to generate alt text. Otherwise just use the prompt as alt text --- .../Image_Generation/Image_Generation.php | 2 ++ .../functions/upload-image.ts | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index 707be423..c23324fc 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -14,6 +14,7 @@ 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; @@ -132,6 +133,7 @@ public function enqueue_assets( string $hook_suffix ): void { 'ImageGenerationData', array( 'enabled' => $this->is_enabled(), + 'altTextEnabled' => ( new Alt_Text_Generation() )->is_enabled(), 'generateImagePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', 'getContextPath' => 'wp-abilities/v1/abilities/ai/get-post-details/run', diff --git a/src/experiments/image-generation/functions/upload-image.ts b/src/experiments/image-generation/functions/upload-image.ts index 3d573568..0d6f66b4 100644 --- a/src/experiments/image-generation/functions/upload-image.ts +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -4,6 +4,10 @@ import apiFetch from '@wordpress/api-fetch'; import { __, sprintf } from '@wordpress/i18n'; +/** + * Internal dependencies + */ +import { generateAltText } from '../../../utils/generate-alt-text'; const { aiImageGenerationData } = window as any; /** @@ -37,6 +41,20 @@ export async function uploadImage( { url: string; title: string; } > { + let altText = prompt; + + // If alt text generation is enabled, try generating alt text. + if ( aiImageGenerationData?.altTextEnabled ) { + try { + altText = await generateAltText( + undefined, + `data:image/png;base64,${ image.data }` + ); + } catch ( error ) { + altText = prompt; + } + } + return apiFetch( { path: aiImageGenerationData?.importPath ?? '', method: 'POST', @@ -56,6 +74,7 @@ export async function uploadImage( { new Date().toLocaleDateString(), prompt ), + alt_text: altText, meta: [ { key: 'ai_generated', From 2589ce69c7b287253c4268a447b919bb9ed1b223 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 07:58:04 -0700 Subject: [PATCH 45/54] Change the upload image function to use the new runAbility helper --- .../functions/upload-image.ts | 59 ++++++++----------- src/experiments/image-generation/types.ts | 24 ++++++++ 2 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 src/experiments/image-generation/types.ts diff --git a/src/experiments/image-generation/functions/upload-image.ts b/src/experiments/image-generation/functions/upload-image.ts index 0d6f66b4..82866df9 100644 --- a/src/experiments/image-generation/functions/upload-image.ts +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -1,13 +1,14 @@ /** * WordPress dependencies */ -import apiFetch from '@wordpress/api-fetch'; import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import { generateAltText } from '../../../utils/generate-alt-text'; +import { runAbility } from '../../../utils/run-ability'; +import type { ImageImportAbilityInput } from '../types'; const { aiImageGenerationData } = window as any; /** @@ -41,49 +42,41 @@ export async function uploadImage( { url: string; title: string; } > { - let altText = prompt; + const params: ImageImportAbilityInput = { + data: image.data, + mime_type: 'image/png', + title: __( 'AI Generated Image', 'ai' ), + 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', + }, + ], + }; + + params.alt_text = prompt; // If alt text generation is enabled, try generating alt text. if ( aiImageGenerationData?.altTextEnabled ) { try { - altText = await generateAltText( + params.alt_text = await generateAltText( undefined, `data:image/png;base64,${ image.data }` ); } catch ( error ) { - altText = prompt; + params.alt_text = prompt; } } - return apiFetch( { - path: aiImageGenerationData?.importPath ?? '', - method: 'POST', - data: { - input: { - data: image.data, - mime_type: 'image/png', - title: __( 'AI Generated Image', 'ai' ), - 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 - ), - alt_text: altText, - meta: [ - { - key: 'ai_generated', - value: '1', - }, - ], - }, - }, - } ) + return await runAbility( 'ai/image-import', params ) .then( ( response: any ) => { if ( response && diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts new file mode 100644 index 00000000..76aa899b --- /dev/null +++ b/src/experiments/image-generation/types.ts @@ -0,0 +1,24 @@ +/** + * Type definitions for image generation experiment. + */ + +/** + * 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; +} From 31079496a0aa07bf26972318ac625a064d9b4ba8 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 08:02:41 -0700 Subject: [PATCH 46/54] Change the generate image function to use the new runAbility helper --- .../functions/generate-image.ts | 23 ++++++------------- src/experiments/image-generation/types.ts | 9 ++++++++ 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/experiments/image-generation/functions/generate-image.ts b/src/experiments/image-generation/functions/generate-image.ts index 2729ea3f..a14f6217 100644 --- a/src/experiments/image-generation/functions/generate-image.ts +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -1,16 +1,11 @@ -/** - * WordPress dependencies - */ -import apiFetch from '@wordpress/api-fetch'; - /** * Internal dependencies */ import { formatContext } from './format-context'; import { getContext } from './get-context'; import { generatePrompt } from './generate-prompt'; - -const { aiImageGenerationData } = window as any; +import { runAbility } from '../../../utils/run-ability'; +import type { ImageGenerationAbilityInput } from '../types'; /** * Generates an image for the given post ID and content. @@ -58,15 +53,11 @@ export async function generateImage( ); } - return apiFetch( { - path: aiImageGenerationData?.generateImagePath ?? '', - method: 'POST', - data: { - input: { - prompt, - }, - }, - } ) + const params: ImageGenerationAbilityInput = { + prompt, + }; + + return runAbility( 'ai/image-generation', params ) .then( ( response ) => { if ( response && typeof response === 'object' ) { const result = response as { prompt?: string }; diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts index 76aa899b..1a1d4931 100644 --- a/src/experiments/image-generation/types.ts +++ b/src/experiments/image-generation/types.ts @@ -22,3 +22,12 @@ export interface ImageImportAbilityInput { | { key: string; value: string }[] | undefined; } + + +/** + * Input parameters for the ai/image-generation ability. + */ +export interface ImageGenerationAbilityInput { + prompt: string; + [ key: string ]: string | undefined; +} From d5d7314771136ad79f8c524c9e5911d03643edd6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 08:05:57 -0700 Subject: [PATCH 47/54] Change the generate image prompt function to use the new runAbility helper --- .../functions/generate-prompt.ts | 23 ++++++++----------- src/experiments/image-generation/types.ts | 11 ++++++++- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/experiments/image-generation/functions/generate-prompt.ts b/src/experiments/image-generation/functions/generate-prompt.ts index a4e8a72b..5ae77d49 100644 --- a/src/experiments/image-generation/functions/generate-prompt.ts +++ b/src/experiments/image-generation/functions/generate-prompt.ts @@ -1,9 +1,8 @@ /** - * WordPress dependencies + * Internal dependencies */ -import apiFetch from '@wordpress/api-fetch'; - -const { aiImageGenerationData } = window as any; +import { runAbility } from '../../../utils/run-ability'; +import type { ImagePromptGenerationAbilityInput } from '../types'; /** * Generates a featured image generation prompt for the given post ID and content. @@ -16,16 +15,12 @@ export async function generatePrompt( content: string, context: string ): Promise< string > { - return apiFetch( { - path: aiImageGenerationData?.generatePromptPath ?? '', - method: 'POST', - data: { - input: { - content, - context, - }, - }, - } ) + const params: ImagePromptGenerationAbilityInput = { + content, + context, + }; + + return await runAbility( 'ai/image-prompt-generation', params ) .then( ( response ) => { if ( response && typeof response === 'string' ) { return response; diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts index 1a1d4931..43170135 100644 --- a/src/experiments/image-generation/types.ts +++ b/src/experiments/image-generation/types.ts @@ -23,7 +23,6 @@ export interface ImageImportAbilityInput { | undefined; } - /** * Input parameters for the ai/image-generation ability. */ @@ -31,3 +30,13 @@ 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; +} From 6533439c2b9f7aff24907d853e10b2b39f6b8a5c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 08:22:10 -0700 Subject: [PATCH 48/54] Extract out our types to better manage those --- .../image-generation/components/AILabel.tsx | 9 ++- .../functions/format-context.ts | 11 ++- .../functions/generate-image.ts | 47 +++--------- .../image-generation/functions/get-context.ts | 34 +++------ .../functions/upload-image.ts | 40 +++------- src/experiments/image-generation/types.ts | 74 +++++++++++++++++++ 6 files changed, 121 insertions(+), 94 deletions(-) diff --git a/src/experiments/image-generation/components/AILabel.tsx b/src/experiments/image-generation/components/AILabel.tsx index 58631c5e..17226bcc 100644 --- a/src/experiments/image-generation/components/AILabel.tsx +++ b/src/experiments/image-generation/components/AILabel.tsx @@ -6,13 +6,18 @@ 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 {string} label - The label to display. + * @param {AILabelProps} props - Component props. * @return {JSX.Element} The AILabel component. */ -export default function AILabel( { label }: { label: string } ): JSX.Element { +export default function AILabel( { label }: AILabelProps ): JSX.Element { const featuredImageId = select( editorStore ).getEditedPostAttribute( 'featured_media' ); const [ image, setImage ] = useState< any >( null ); diff --git a/src/experiments/image-generation/functions/format-context.ts b/src/experiments/image-generation/functions/format-context.ts index 747a304e..77d68e86 100644 --- a/src/experiments/image-generation/functions/format-context.ts +++ b/src/experiments/image-generation/functions/format-context.ts @@ -1,12 +1,15 @@ +/** + * Internal dependencies + */ +import type { ContextRecord } from '../types'; + /** * Formats an object as a string with "Key: Value" pairs on separate lines. * - * @param {Record} obj The object to format. + * @param {ContextRecord} obj The object to format. * @return {string} The formatted string. */ -export function formatContext( - obj: Record< string, string | undefined > -): string { +export function formatContext( obj: ContextRecord ): string { return Object.entries( obj ) .filter( ( [ , value ] ) => diff --git a/src/experiments/image-generation/functions/generate-image.ts b/src/experiments/image-generation/functions/generate-image.ts index a14f6217..282c5926 100644 --- a/src/experiments/image-generation/functions/generate-image.ts +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -5,38 +5,27 @@ import { formatContext } from './format-context'; import { getContext } from './get-context'; import { generatePrompt } from './generate-prompt'; import { runAbility } from '../../../utils/run-ability'; -import type { ImageGenerationAbilityInput } from '../types'; +import type { + GeneratedImageData, + ImageGenerationAbilityInput, + 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. - * @return {Promise<{ image: { data: string; provider_metadata: { id: string; name: string; type: string; }; model_metadata: { id: string; name: string; }; }; prompt: string; }>} A promise that resolves to the generated image data. + * @return {Promise} A promise that resolves to the generated image data. */ export async function generateImage( postId: number, content: string -): Promise< { - image: { - data: string; - provider_metadata: { id: string; name: string; type: string }; - model_metadata: { id: string; name: string }; - }; - prompt: string; -} > { - let context: { - title: string; - type: string; - content?: string; - }; +): Promise< GeneratedImageData > { + let context: PostContext; try { - context = ( await getContext( postId ) ) as { - title: string; - type: string; - content?: string; - }; + context = ( await getContext( postId ) ) as PostContext; } catch ( error: any ) { throw new Error( `Failed to get post context: ${ error.message || error }` @@ -57,26 +46,12 @@ export async function generateImage( prompt, }; - return runAbility( 'ai/image-generation', params ) + 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 { - image: { - data: string; - provider_metadata: { - id: string; - name: string; - type: string; - }; - model_metadata: { - id: string; - name: string; - }; - }; - prompt: string; - }; + return result as GeneratedImageData; } throw new Error( 'Invalid response from generate image' ); diff --git a/src/experiments/image-generation/functions/get-context.ts b/src/experiments/image-generation/functions/get-context.ts index c1203d1f..9317f671 100644 --- a/src/experiments/image-generation/functions/get-context.ts +++ b/src/experiments/image-generation/functions/get-context.ts @@ -1,35 +1,25 @@ /** - * WordPress dependencies + * Internal dependencies */ -import apiFetch from '@wordpress/api-fetch'; - -const { aiImageGenerationData } = window as any; +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<{ title: string; type: string }>} A promise that resolves to the context. + * @return {Promise} A promise that resolves to the context. */ -export async function getContext( - postId: number -): Promise< { title: string; type: string } > { - return apiFetch( { - path: aiImageGenerationData?.getContextPath ?? '', - method: 'POST', - data: { - input: { - post_id: postId, - fields: [ 'title', 'type' ], - }, - }, - } ) +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 { - title: string; - type: string; - }; + return response as PostContext; } throw new Error( 'Invalid response from get context' ); diff --git a/src/experiments/image-generation/functions/upload-image.ts b/src/experiments/image-generation/functions/upload-image.ts index 82866df9..60c376fa 100644 --- a/src/experiments/image-generation/functions/upload-image.ts +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -8,40 +8,24 @@ import { __, sprintf } from '@wordpress/i18n'; */ import { generateAltText } from '../../../utils/generate-alt-text'; import { runAbility } from '../../../utils/run-ability'; -import type { ImageImportAbilityInput } from '../types'; +import type { + GeneratedImageData, + ImageImportAbilityInput, + UploadedImage, +} from '../types'; + const { aiImageGenerationData } = window as any; /** * Uploads an image to the media library. * - * @param {Object} image The image object. - * @param {Object} image.image The image data. - * @param {string} image.image.data The base64 encoded image data. - * @param {Object} image.image.provider_metadata The provider metadata. - * @param {string} image.image.provider_metadata.id The provider ID. - * @param {string} image.image.provider_metadata.name The provider name. - * @param {string} image.image.provider_metadata.type The provider type. - * @param {Object} image.image.model_metadata The model metadata. - * @param {string} image.image.model_metadata.id The model ID. - * @param {string} image.image.model_metadata.name The model name. - * @param {string} image.prompt The prompt used to generate the image. - * @return {Promise<{ id: number; url: string; title: string }>} A promise that resolves to the uploaded image data. + * @param {GeneratedImageData} imageData The generated image data (from generateImage). + * @return {Promise} A promise that resolves to the uploaded image data. */ export async function uploadImage( { image, prompt, -}: { - image: { - data: string; - provider_metadata: { id: string; name: string; type: string }; - model_metadata: { id: string; name: string }; - }; - prompt: string; -} ): Promise< { - id: number; - url: string; - title: string; -} > { +}: GeneratedImageData ): Promise< UploadedImage > { const params: ImageImportAbilityInput = { data: image.data, mime_type: 'image/png', @@ -83,11 +67,7 @@ export async function uploadImage( { typeof response === 'object' && 'image' in response ) { - return response.image as { - id: number; - url: string; - title: string; - }; + return response.image as UploadedImage; } throw new Error( 'Invalid response from image import' ); diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts index 43170135..e66f3e64 100644 --- a/src/experiments/image-generation/types.ts +++ b/src/experiments/image-generation/types.ts @@ -2,6 +2,71 @@ * 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. */ @@ -40,3 +105,12 @@ export interface ImagePromptGenerationAbilityInput { 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; +} From b06c238d217dd122d4ed88a2d524e4a2def23bad Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 08:44:28 -0700 Subject: [PATCH 49/54] Add a progress message below the generate button that shows what step we're on --- .../components/GenerateFeaturedImage.tsx | 32 +++++++++++++++++-- .../functions/generate-image.ts | 20 ++++++++++-- .../functions/upload-image.ts | 18 ++++++++--- src/experiments/image-generation/types.ts | 5 +++ 4 files changed, 64 insertions(+), 11 deletions(-) diff --git a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx index 1b182d93..ce3454f7 100644 --- a/src/experiments/image-generation/components/GenerateFeaturedImage.tsx +++ b/src/experiments/image-generation/components/GenerateFeaturedImage.tsx @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { Button } from '@wordpress/components'; +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'; @@ -30,6 +30,9 @@ export default function GenerateFeaturedImage(): JSX.Element { 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' ) @@ -40,6 +43,7 @@ export default function GenerateFeaturedImage(): JSX.Element { */ const handleGenerate = async () => { setIsGenerating( true ); + setProgressMessage( null ); ( dispatch( noticesStore ) as any ).removeNotice( 'ai_image_generation_error' ); @@ -47,9 +51,12 @@ export default function GenerateFeaturedImage(): JSX.Element { try { const generatedImageData = await generateImage( postId as number, - content + content, + { onProgress: setProgressMessage } ); - const importedImage = await uploadImage( generatedImageData ); + const importedImage = await uploadImage( generatedImageData, { + onProgress: setProgressMessage, + } ); editPost( { featured_media: importedImage.id, } ); @@ -60,6 +67,7 @@ export default function GenerateFeaturedImage(): JSX.Element { } ); } finally { setIsGenerating( false ); + setProgressMessage( null ); } }; @@ -75,6 +83,24 @@ export default function GenerateFeaturedImage(): JSX.Element { > { buttonLabel } + { progressMessage && ( +
+ { progressMessage } + +
+ ) }
); diff --git a/src/experiments/image-generation/functions/generate-image.ts b/src/experiments/image-generation/functions/generate-image.ts index 282c5926..dec0431f 100644 --- a/src/experiments/image-generation/functions/generate-image.ts +++ b/src/experiments/image-generation/functions/generate-image.ts @@ -1,3 +1,8 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + /** * Internal dependencies */ @@ -8,20 +13,26 @@ 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 {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 + content: string, + options?: { onProgress?: ImageProgressCallback } ): Promise< GeneratedImageData > { + const onProgress = options?.onProgress; + let context: PostContext; try { @@ -35,6 +46,7 @@ export async function generateImage( let prompt: string; try { + onProgress?.( __( 'Generating image prompt', 'ai' ) ); prompt = await generatePrompt( content, formatContext( context ) ); } catch ( error: any ) { throw new Error( @@ -42,6 +54,8 @@ export async function generateImage( ); } + onProgress?.( __( 'Generating image', 'ai' ) ); + const params: ImageGenerationAbilityInput = { prompt, }; diff --git a/src/experiments/image-generation/functions/upload-image.ts b/src/experiments/image-generation/functions/upload-image.ts index 60c376fa..730eee57 100644 --- a/src/experiments/image-generation/functions/upload-image.ts +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -11,6 +11,7 @@ import { runAbility } from '../../../utils/run-ability'; import type { GeneratedImageData, ImageImportAbilityInput, + ImageProgressCallback, UploadedImage, } from '../types'; @@ -19,13 +20,17 @@ const { aiImageGenerationData } = window as any; /** * Uploads an image to the media library. * - * @param {GeneratedImageData} imageData The generated image data (from generateImage). + * @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 ): Promise< UploadedImage > { +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', @@ -51,6 +56,7 @@ export async function uploadImage( { // 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 }` @@ -60,6 +66,8 @@ export async function uploadImage( { } } + onProgress?.( __( 'Importing image', 'ai' ) ); + return await runAbility( 'ai/image-import', params ) .then( ( response: any ) => { if ( diff --git a/src/experiments/image-generation/types.ts b/src/experiments/image-generation/types.ts index e66f3e64..37856b8b 100644 --- a/src/experiments/image-generation/types.ts +++ b/src/experiments/image-generation/types.ts @@ -114,3 +114,8 @@ export interface GetPostDetailsAbilityInput { fields?: string[]; [ key: string ]: string | number | string[] | undefined; } + +/** + * Callback type for image generation progress messages. + */ +export type ImageProgressCallback = ( message: string ) => void; From e31cf8828d667090224701177f3d20efb13f0d5c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 08:55:51 -0700 Subject: [PATCH 50/54] Update docs --- docs/experiments/image-generation.md | 54 ++++++++++++---------------- 1 file changed, 23 insertions(+), 31 deletions(-) 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` From 658511bb9665a058977e0b7491a4ac63990b41c6 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 08:56:16 -0700 Subject: [PATCH 51/54] Remove attributes that aren't needed --- includes/Experiments/Image_Generation/Image_Generation.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index c23324fc..b94f678c 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -134,10 +134,6 @@ public function enqueue_assets( string $hook_suffix ): void { array( 'enabled' => $this->is_enabled(), 'altTextEnabled' => ( new Alt_Text_Generation() )->is_enabled(), - 'generateImagePath' => 'wp-abilities/v1/abilities/ai/' . $this->get_id() . '/run', - 'importPath' => 'wp-abilities/v1/abilities/ai/image-import/run', - 'getContextPath' => 'wp-abilities/v1/abilities/ai/get-post-details/run', - 'generatePromptPath' => 'wp-abilities/v1/abilities/ai/image-prompt-generation/run', ) ); } From c0b0714571e99b78479b6e8fb83d103f872f79b7 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 09:08:07 -0700 Subject: [PATCH 52/54] Add utility function to trim text to a certain length. Use this to set the image title to be a trimmed version of the alt text --- .../functions/upload-image.ts | 6 ++++- src/utils/text.ts | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) create mode 100644 src/utils/text.ts diff --git a/src/experiments/image-generation/functions/upload-image.ts b/src/experiments/image-generation/functions/upload-image.ts index 730eee57..2a174f90 100644 --- a/src/experiments/image-generation/functions/upload-image.ts +++ b/src/experiments/image-generation/functions/upload-image.ts @@ -8,6 +8,7 @@ import { __, sprintf } from '@wordpress/i18n'; */ import { generateAltText } from '../../../utils/generate-alt-text'; import { runAbility } from '../../../utils/run-ability'; +import { trimText } from '../../../utils/text'; import type { GeneratedImageData, ImageImportAbilityInput, @@ -34,7 +35,6 @@ export async function uploadImage( const params: ImageImportAbilityInput = { data: image.data, mime_type: 'image/png', - title: __( 'AI Generated Image', 'ai' ), 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' ), @@ -51,6 +51,7 @@ export async function uploadImage( ], }; + // Use the prompt as alt text by default. params.alt_text = prompt; // If alt text generation is enabled, try generating alt text. @@ -66,6 +67,9 @@ export async function uploadImage( } } + // 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 ) 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; +} From b4903e5ae6dcf785de4d9d7e02e356877c12d945 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 09:09:20 -0700 Subject: [PATCH 53/54] Fix lint errors --- includes/Experiments/Image_Generation/Image_Generation.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/includes/Experiments/Image_Generation/Image_Generation.php b/includes/Experiments/Image_Generation/Image_Generation.php index b94f678c..452ee117 100644 --- a/includes/Experiments/Image_Generation/Image_Generation.php +++ b/includes/Experiments/Image_Generation/Image_Generation.php @@ -132,8 +132,8 @@ public function enqueue_assets( string $hook_suffix ): void { 'image_generation', 'ImageGenerationData', array( - 'enabled' => $this->is_enabled(), - 'altTextEnabled' => ( new Alt_Text_Generation() )->is_enabled(), + 'enabled' => $this->is_enabled(), + 'altTextEnabled' => ( new Alt_Text_Generation() )->is_enabled(), ) ); } From 04c2df456e81be172e48c114f93a2f5a6f7c4fee Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Fri, 6 Feb 2026 09:19:25 -0700 Subject: [PATCH 54/54] Fix tests --- .../Abilities/Image_GenerationTest.php | 39 ++++++++++++------- .../experiments/image-generation.spec.js | 2 +- 2 files changed, 27 insertions(+), 14 deletions(-) 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/e2e/specs/experiments/image-generation.spec.js b/tests/e2e/specs/experiments/image-generation.spec.js index e70576d6..74f39b76 100644 --- a/tests/e2e/specs/experiments/image-generation.spec.js +++ b/tests/e2e/specs/experiments/image-generation.spec.js @@ -87,7 +87,7 @@ test.describe( 'Image Generation Experiment', () => { await expect( imageContainer ).toHaveAttribute( 'aria-label', - 'AI Generated Image' + 'Edit or Delete Your First WordPress Post to Begin Your Blogging Adventure' ); await expect( imageContainer.locator( 'img' ) ).toBeVisible();