Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
2888dfb
Add Alt Text Generation experiment
Jameswlepage Dec 19, 2025
42bae60
Merge branch 'develop' into feature/alt-text-generation
jeffpaul Dec 19, 2025
f5e5cb6
Merge branch 'develop' into feature/alt-text-generation
dkotter Jan 28, 2026
ea83864
Move the Ability to the Image directory. Minor changes to match codin…
dkotter Jan 28, 2026
8783bc6
Add function to get preferred vision models
dkotter Jan 28, 2026
05bd9d7
Load the proper Ability. Minor code improvements
dkotter Jan 28, 2026
a8b014b
Add missing dependencies
dkotter Jan 28, 2026
04d7141
Fix linting and typescript errors
dkotter Jan 28, 2026
20f681d
Switch from fully injecting our generate button via JS and instead us…
dkotter Jan 28, 2026
fd99527
Add a meta box to the attachment edit screen that has the generate bu…
dkotter Jan 28, 2026
6a86fc6
Update docs
dkotter Jan 28, 2026
68adddd
Add unit tests
dkotter Jan 28, 2026
3b05a50
Fix filter name
dkotter Jan 28, 2026
61c9465
Add E2E tests
dkotter Jan 28, 2026
8f6a7d2
Fix linting errors
dkotter Jan 28, 2026
150ca22
Update system instructions
dkotter Jan 28, 2026
1b42e4b
Apply code review suggestions. Switch from strlen to mb_strlen and ap…
dkotter Jan 28, 2026
ae72054
Merge branch 'develop' into feature/alt-text-generation
dkotter Feb 5, 2026
2e15908
Remove the display of current alt text, as this is already displayed …
dkotter Feb 5, 2026
83acc65
Pass the context through our normalize function to strip things like …
dkotter Feb 5, 2026
eb3f0f5
Minor update to system instructions
dkotter Feb 5, 2026
b2ea0df
When generating alt text for in-content images, grab the content, rep…
dkotter Feb 5, 2026
48c7398
Add needed depedency
dkotter Feb 5, 2026
6544f39
Merge branch 'develop' into feature/alt-text-generation
dkotter Feb 5, 2026
6763693
Prevent direct file access
dkotter Feb 5, 2026
5a470a0
Merge branch 'develop' into feature/alt-text-generation
dkotter Feb 5, 2026
a803d4a
Hide the textarea label
dkotter Feb 5, 2026
7de6575
Merge branch 'develop' into feature/alt-text-generation
jeffpaul Feb 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
328 changes: 328 additions & 0 deletions docs/experiments/alt-text-generation.md

Large diffs are not rendered by default.

834 changes: 834 additions & 0 deletions docs/experiments/image-generation.md

Large diffs are not rendered by default.

538 changes: 538 additions & 0 deletions includes/Abilities/Image/Alt_Text_Generation.php

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions includes/Abilities/Image/alt-text-system-instruction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php
/**
* System instruction for the Alt Text Generation ability.
*
* @package WordPress\AI\Abilities\Image
*/

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

// phpcs:ignore Squiz.PHP.Heredoc.NotAllowed
return <<<'INSTRUCTION'
You are an accessibility expert that generates alt text for images on websites.

Goal: Analyze the provided image and generate concise, descriptive alt text that accurately describes the image content for users who cannot see it. The alt text should be optimized for screen readers and accessibility compliance. If additional context is provided, use it to generate a more relevant alt text.

Requirements for the alt text:

- Be concise: Keep it under 125 characters when possible
- Be descriptive: Describe what is visually present in the image
- Be objective: Describe what you see, not interpretations or assumptions
- Avoid redundancy: Do not start with "Image of", "Picture of", or "Photo of"
- Include relevant details: People, objects, actions, colors, and context when meaningful
- Consider context: If context is provided, ensure the alt text is relevant to the surrounding content
- Plain text only: No markdown, quotes, or special formatting

For images containing text, include the text in your description if it's essential to understanding the image.

Respond with only the alt text, nothing else.
INSTRUCTION;
1 change: 1 addition & 0 deletions includes/Experiment_Loader.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
*
* @param \WordPress\AI\Experiment_Registry $registry The experiment registry instance.
*/
do_action( 'ai_experiments_register_experiments', $this->registry );

Check warning on line 94 in includes/Experiment_Loader.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_register_experiments".
}

/**
Expand All @@ -106,6 +106,7 @@
$experiment_classes = array(
\WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class,
\WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class,
\WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation::class,
\WordPress\AI\Experiments\Image_Generation\Image_Generation::class,
\WordPress\AI\Experiments\Summarization\Summarization::class,
\WordPress\AI\Experiments\Title_Generation\Title_Generation::class,
Expand All @@ -121,7 +122,7 @@
*
* @param array $experiment_classes Array of experiment class names or instances.
*/
$items = apply_filters( 'ai_experiments_default_experiment_classes', $experiment_classes );

Check warning on line 125 in includes/Experiment_Loader.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_default_experiment_classes".

$experiments = array();
foreach ( $items as $item ) {
Expand Down Expand Up @@ -192,7 +193,7 @@
*
* @param bool $enabled Whether to enable AI experiments.
*/
$experiments_enabled = apply_filters( 'ai_experiments_enabled', true );

Check warning on line 196 in includes/Experiment_Loader.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_enabled".

if ( ! $experiments_enabled ) {
$this->initialized = true;
Expand All @@ -214,7 +215,7 @@
*
* @since 0.1.0
*/
do_action( 'ai_experiments_initialized' );

Check warning on line 218 in includes/Experiment_Loader.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_initialized".

$this->initialized = true;
}
Expand Down
225 changes: 225 additions & 0 deletions includes/Experiments/Alt_Text_Generation/Alt_Text_Generation.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
<?php
/**
* Alt text generation experiment implementation.
*
* @package WordPress\AI
*/

declare( strict_types=1 );

namespace WordPress\AI\Experiments\Alt_Text_Generation;

use WordPress\AI\Abilities\Image\Alt_Text_Generation as Alt_Text_Generation_Ability;
use WordPress\AI\Abstracts\Abstract_Experiment;
use WordPress\AI\Asset_Loader;

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Alt text generation experiment.
*
* Generates descriptive alt text for images using AI vision models.
*
* @since x.x.x
*/
class Alt_Text_Generation extends Abstract_Experiment {
/**
* Tracks whether the media-focused assets have already been enqueued.
*
* @since x.x.x
*
* @var bool
*/
private bool $media_assets_enqueued = false;

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function load_experiment_metadata(): array {
return array(
'id' => 'alt-text-generation',
'label' => __( 'Alt Text Generation', 'ai' ),
'description' => __( 'Generates descriptive alt text for images using AI vision models.', 'ai' ),
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
public function register(): void {
add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) );
add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_editor_assets' ) );
add_action( 'wp_enqueue_media', array( $this, 'enqueue_media_frame_assets' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'maybe_enqueue_media_library_assets' ) );
add_action( 'add_meta_boxes_attachment', array( $this, 'setup_attachment_meta_box' ) );
add_filter( 'attachment_fields_to_edit', array( $this, 'add_button_to_media_modal' ), 10, 2 );
}

/**
* Registers any needed abilities.
*
* @since x.x.x
*/
public function register_abilities(): void {
wp_register_ability(
'ai/' . $this->get_id(),
array(
'label' => $this->get_label(),
'description' => $this->get_description(),
'ability_class' => Alt_Text_Generation_Ability::class,
),
);
}

/**
* Enqueues block editor assets.
*
* @since x.x.x
*/
public function enqueue_editor_assets(): void {
Asset_Loader::enqueue_script( 'alt_text_generation', 'experiments/alt-text-generation' );
Asset_Loader::localize_script(
'alt_text_generation',
'AltTextGenerationData',
array(
'enabled' => $this->is_enabled(),
)
);

$this->maybe_enqueue_media_script();
}

/**
* Enqueues assets whenever the core media modal is registered.
*
* @since x.x.x
*/
public function enqueue_media_frame_assets(): void {
$this->maybe_enqueue_media_script();
}

/**
* Conditionally enqueues assets on media-related admin screens (e.g., upload.php).
*
* @since x.x.x
*
* @param string $hook_suffix Current admin page hook suffix.
*/
public function maybe_enqueue_media_library_assets( string $hook_suffix ): void {
if ( ! $this->is_enabled() ) {
return;
}

if ( in_array( $hook_suffix, array( 'upload.php', 'media-new.php' ), true ) ) {
$this->maybe_enqueue_media_script();
return;
}

$screen = function_exists( 'get_current_screen' ) ? get_current_screen() : null;

if ( ! $screen || 'attachment' !== $screen->post_type ) {
return;
}

$this->maybe_enqueue_media_script();
}

/**
* Shared helper to enqueue and localize the media UI script once per request.
*
* @since x.x.x
*/
private function maybe_enqueue_media_script(): void {
if ( $this->media_assets_enqueued || ! $this->is_enabled() ) {
return;
}

Asset_Loader::enqueue_script( 'alt_text_generation_media', 'experiments/alt-text-generation-media' );
Asset_Loader::localize_script(
'alt_text_generation_media',
'AltTextGenerationMediaData',
array(
'enabled' => $this->is_enabled(),
)
);

$this->media_assets_enqueued = true;
}

/**
* Sets up the attachment meta box.
*
* Adds a meta box to the attachment edit screen that contains
* the Generate/Regenerate button.
*
* @since x.x.x
*
* @param \WP_Post $post The attachment post.
*/
public function setup_attachment_meta_box( \WP_Post $post ): void {
if (
! $this->is_enabled() ||
! wp_attachment_is_image( $post )
) {
return;
}

add_meta_box(
'ai_alt_text_generation',
__( 'AI Alt Text', 'ai' ),
array( $this, 'render_attachment_meta_box' ),
'attachment',
);
}

/**
* Renders the attachment meta box content.
*
* @since x.x.x
*
* @param \WP_Post $post The attachment post.
*/
public function render_attachment_meta_box( \WP_Post $post ): void {
$button_text = empty( get_post_meta( $post->ID, '_wp_attachment_image_alt', true ) ) ? __( 'Generate', 'ai' ) : __( 'Regenerate', 'ai' );

echo '<div class="ai-alt-text-media-actions" style="margin-top: 16px; max-width: 150px;">';
echo '<button id="ai-alt-text-generate-button" class="button button-secondary" type="button" data-attachment-id="' . absint( $post->ID ) . '">' . esc_html( $button_text ) . '</button><span class="spinner" aria-hidden="true" style="margin-left: 8px;"></span><p class="description" aria-live="polite" style="margin-top: 10px; line-height: 1.3;"></p>';
echo '</div>';
}

/**
* Adds a button to the media modal to generate alt text.
*
* @since x.x.x
*
* @param array<string, mixed> $fields The attachment fields.
* @param \WP_Post|null $post The attachment post.
* @return array<string, mixed> The attachment fields with the button added.
*/
public function add_button_to_media_modal( array $fields, ?\WP_Post $post ): array {
if (
! $this->is_enabled() ||
null === $post ||
! wp_attachment_is_image( $post )
) {
return $fields;
}

$button_text = empty( get_post_meta( $post->ID, '_wp_attachment_image_alt', true ) ) ? __( 'Generate', 'ai' ) : __( 'Regenerate', 'ai' );

$fields['ai_alt_text'] = array(
'label' => __( 'AI Alt Text', 'ai' ),
'input' => 'html',
'show_in_edit' => false,
'html' => '<div class="ai-alt-text-media-actions"><button id="ai-alt-text-generate-button" class="button button-secondary" type="button" data-attachment-id="' . absint( $post->ID ) . '">' . esc_html( $button_text ) . '</button><span class="spinner" aria-hidden="true" style="margin-left: 8px;"></span><p class="description" aria-live="polite" style="margin-top: 6px; font-size: 12px;"></p></div>',
);

return $fields;
}
}
34 changes: 34 additions & 0 deletions includes/helpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
*
* @return string The filtered Post content.
*/
$content = (string) apply_filters( 'ai_experiments_pre_normalize_content', $content );

Check warning on line 46 in includes/helpers.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_pre_normalize_content".

// Strip HTML entities.
$content = preg_replace( '/&#?[a-z0-9]{2,8};/i', '', $content ) ?? $content;
Expand All @@ -66,7 +66,7 @@
*
* @return string The filtered normalized content.
*/
$content = (string) apply_filters( 'ai_experiments_normalize_content', (string) $content );

Check warning on line 69 in includes/helpers.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_normalize_content".

return trim( $content );
}
Expand Down Expand Up @@ -165,7 +165,7 @@
* @param array<int, array{string, string}> $preferred_models The preferred models for text generation.
* @return array<int, array{string, string}> The filtered preferred models.
*/
return (array) apply_filters( 'ai_experiments_preferred_models_for_text_generation', $preferred_models );

Check warning on line 168 in includes/helpers.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_preferred_models_for_text_generation".
}

/**
Expand Down Expand Up @@ -243,9 +243,43 @@
* @param array<int, array{string, string}> $preferred_models The preferred image models.
* @return array<int, array{string, string}> The filtered preferred image models.
*/
return (array) apply_filters( 'ai_experiments_preferred_image_models', $preferred_models );

Check warning on line 246 in includes/helpers.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_preferred_image_models".
}

/**
* Returns the preferred vision models.
*
* @since x.x.x
*
* @return array<int, array{string, string}> The preferred vision models.
*/
function get_preferred_vision_models(): array {
$preferred_models = array(
array(
'anthropic',
'claude-haiku-4-5-20251001',
),
array(
'google',
'gemini-2.5-flash',
),
array(
'openai',
'gpt-5-nano',
),
);

/**
* Filters the preferred vision models.
*
* @since x.x.x
*
* @param array<int, array{string, string}> $preferred_models The preferred vision models.
* @return array<int, array{string, string}> The filtered preferred vision models.
*/
return (array) apply_filters( 'ai_experiments_preferred_vision_models', $preferred_models );

Check warning on line 280 in includes/helpers.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_preferred_vision_models".
}

/**
* Checks if we have AI credentials set.
*
Expand Down Expand Up @@ -295,7 +329,7 @@
* @param bool|null $has_valid_credentials Whether valid credentials are available. Return null to use default check.
* @return bool|null True if valid credentials are available, false otherwise, or null to use default check.
*/
$valid = apply_filters( 'ai_experiments_pre_has_valid_credentials_check', null );

Check warning on line 332 in includes/helpers.php

View workflow job for this annotation

Image GitHub Actions / Run Plugin Check

WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound

Hook names invoked by a theme/plugin should start with the theme/plugin prefix. Found: "ai_experiments_pre_has_valid_credentials_check".
if ( null !== $valid ) {
return (bool) $valid;
}
Expand Down
Loading
Loading