Image Manager > AI assistants). An approved proposal runs through the same reviewed owner path as
* AffStackDev AI's projects (Reviewed_Store::execute): prepared again, checked against what the AI was shown, applied
* under the owner lock and read back. Nothing is applied without an administrator's approval.
*
* @package AffStackDevImageManager
*/
namespace AffStackDev\ImageManager;
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/** The proposal store, the approval screen and its form handler. */
final class AI_Proposals {
const INDEX = 'afsd_im_ai_proposals';
const PREFIX = 'afsd_im_ai_proposal_';
const KEEP = 100;
const ACTION = Hooks::PROPOSAL;
/**
* The administrator that approves: the current user on this single site (the identity reviewed records keep).
*
* @return array
*/
public static function principal(): array {
return array(
'user_id' => get_current_user_id(),
'site' => Reviewed_Inputs::site(),
'native' => true,
);
}
/**
* Whether the current user may read Image Manager through an AI app and approve its changes.
*
* @return bool
*/
public static function can(): bool {
return ! is_multisite() && current_user_can( 'manage_options' ) && current_user_can( 'upload_files' );
}
/**
* One proposal, or null.
*
* @param string $id Proposal ID (a UUID).
* @return array|null
*/
public static function get( string $id ): ?array {
if ( ! preg_match( '/^[a-f0-9-]{36}$/D', $id ) ) {
return null;
}
$value = get_option( self::PREFIX . $id );
return is_array( $value ) ? $value : null;
}
/**
* Proposals, newest first.
*
* @param string $status Only this status, or '' for all.
* @param int $limit At most this many.
* @return array
*/
public static function all( string $status = '', int $limit = self::KEEP ): array {
$out = array();
foreach ( (array) get_option( self::INDEX, array() ) as $id ) {
$p = self::get( (string) $id );
if ( $p && ( '' === $status || $status === $p['status'] ) ) {
$out[] = $p;
}
if ( count( $out ) >= $limit ) {
break;
}
}
return $out;
}
/**
* Save a proposal (and keep the index bounded: the oldest decided ones go first).
*
* @param array $p Proposal.
* @return void
*/
private static function save( array $p ): void {
update_option( self::PREFIX . $p['id'], $p, false );
$index = array_values( array_diff( (array) get_option( self::INDEX, array() ), array( $p['id'] ) ) );
array_unshift( $index, $p['id'] );
// Over the limit: drop the oldest decided proposals; waiting ones stay until the owner decides them.
$over = count( $index ) - self::KEEP;
for ( $i = count( $index ) - 1; $over > 0 && $i >= 0; --$i ) {
$old = self::get( (string) $index[ $i ] );
if ( ! $old || 'waiting' !== $old['status'] ) {
delete_option( self::PREFIX . $index[ $i ] );
array_splice( $index, $i, 1 );
--$over;
}
}
update_option( self::INDEX, $index, false );
}
/**
* Prepare a change exactly as the owner will review it, and keep it waiting for approval.
*
* @param string $operation A reviewed operation (Reviewed_Owner::catalog()).
* @param array $parameters Its parameters.
* @param string $note What the AI says it is for (optional, shown to the owner).
* @param string $via How it arrived (rest, mcp).
* @return array|\WP_Error
*/
public static function propose( string $operation, array $parameters, string $note = '', string $via = 'rest' ) {
if ( ! self::can() ) {
return new \WP_Error( 'im_ai_permission', __( 'An administrator who can manage media proposes Image Manager changes.', 'affstackdev-image-manager' ) );
}
$def = self::definition( $operation );
if ( is_wp_error( $def ) ) {
return $def;
}
$parameters = self::validate( $def, $parameters );
if ( is_wp_error( $parameters ) ) {
return $parameters;
}
$prep = Reviewed_Actions::prepare( $operation, $parameters, self::principal() );
if ( is_wp_error( $prep ) ) {
return $prep;
}
$p = array(
'schema' => 1,
'id' => wp_generate_uuid4(),
'status' => 'waiting',
'created' => time(),
'actor' => get_current_user_id(),
'via' => sanitize_key( $via ),
'note' => sanitize_textarea_field( substr( $note, 0, 500 ) ),
'operation' => $operation,
'title' => (string) $def['title'],
'effect' => (string) $def['effect'],
'parameters' => $parameters,
'state' => $prep['state'],
'expected_after' => $prep['expected_after'],
'changes' => $prep['changes'],
'effects' => $prep['effects'],
'result' => array(
'message' => '',
'state' => array(),
),
);
self::save( $p );
return self::view( $p );
}
/**
* What an AI app and the screen show of a proposal.
*
* @param array $p Proposal.
* @return array
*/
public static function view( array $p ): array {
return array(
'proposal_id' => $p['id'],
'status' => $p['status'],
'operation' => $p['operation'],
'title' => $p['title'],
'created_utc' => gmdate( 'c', (int) $p['created'] ),
'changes' => $p['changes'],
'effects' => $p['effects'],
'result' => $p['result'],
'review_url' => Admin::url( 'ai' ),
'change_status' => 'waiting' === $p['status'] ? __( 'Waiting for the site owner to approve or reject it in WordPress (Media > Image Manager > AI assistants). Nothing has changed yet.', 'affstackdev-image-manager' ) : '',
);
}
/**
* A catalog operation that changes something (inspect is read through its own ability).
*
* @param string $operation Operation.
* @return array|\WP_Error
*/
public static function definition( string $operation ) {
foreach ( Reviewed_Owner::catalog() as $d ) {
if ( $d['operation'] === $operation && 'inspect' !== $operation ) {
return $d;
}
}
return new \WP_Error( 'im_ai_unknown_change', __( 'Image Manager has no such change. List the changes it offers first.', 'affstackdev-image-manager' ) );
}
/**
* Parameters checked and cleaned against the operation's own schema (the one AffStackDev AI checks too).
*
* @param array $def Catalog definition.
* @param array $parameters Parameters.
* @return array|\WP_Error
*/
public static function validate( array $def, array $parameters ) {
$schema = AI_Abilities::clean( $def['input_schema'] );
$valid = rest_validate_value_from_schema( $parameters, $schema, 'parameters' );
if ( is_wp_error( $valid ) ) {
return $valid;
}
$clean = rest_sanitize_value_from_schema( $parameters, $schema, 'parameters' );
return is_array( $clean ) ? $clean : array();
}
/**
* Approve: prepare again, refuse when anything the AI was shown changed, then apply through the reviewed owner path.
*
* @param string $id Proposal ID.
* @return array|\WP_Error The applied result.
*/
public static function approve( string $id ) {
$p = self::get( $id );
if ( ! $p || 'waiting' !== $p['status'] ) {
return new \WP_Error( 'im_ai_not_waiting', __( 'This proposal is no longer waiting.', 'affstackdev-image-manager' ) );
}
$principal = self::principal();
$fresh = Reviewed_Actions::prepare( $p['operation'], $p['parameters'], $principal );
if ( is_wp_error( $fresh ) ) {
return self::decide( $p, 'failed', $fresh->get_error_message() );
}
if ( ! hash_equals( Reviewed_Store::hash( $p['state'] ), Reviewed_Store::hash( $fresh['state'] ) ) || ! hash_equals( Reviewed_Store::hash( $p['expected_after'] ), Reviewed_Store::hash( $fresh['expected_after'] ) ) ) {
return self::decide( $p, 'stale', __( 'Something it would change was changed after the AI proposed it. Nothing was applied; ask the AI to propose it again.', 'affstackdev-image-manager' ) );
}
$p['status'] = 'applying';
$p['decided_by'] = get_current_user_id();
$p['decided'] = time();
self::save( $p );
$result = Reviewed_Store::execute( self::prepared( $p ), self::context( $p ), $principal );
if ( is_wp_error( $result ) ) {
$status = 'im_reviewed_uncertain' === $result->get_error_code() ? 'unknown' : 'failed';
return self::decide( $p, $status, $result->get_error_message() );
}
self::decide( $p, 'applied', (string) ( $result['verification'] ?? '' ), (array) ( $result['state'] ?? array() ) );
return $result;
}
/**
* Look again at an approval whose result was uncertain (read only; nothing is repeated).
*
* @param string $id Proposal ID.
* @return array|\WP_Error
*/
public static function recheck( string $id ) {
$p = self::get( $id );
if ( ! $p || ! in_array( $p['status'], array( 'unknown', 'applying' ), true ) ) {
return new \WP_Error( 'im_ai_not_uncertain', __( 'Only an approval with an uncertain result is checked again.', 'affstackdev-image-manager' ) );
}
$result = Reviewed_Store::reconcile( self::prepared( $p ), self::context( $p )['marker'], self::principal() );
if ( is_wp_error( $result ) ) {
return $result;
}
self::decide( $p, 'applied', (string) ( $result['verification'] ?? '' ), (array) ( $result['state'] ?? array() ) );
return $result;
}
/**
* Reject: nothing is applied.
*
* @param string $id Proposal ID.
* @return array|\WP_Error
*/
public static function reject( string $id ) {
$p = self::get( $id );
if ( ! $p || 'waiting' !== $p['status'] ) {
return new \WP_Error( 'im_ai_not_waiting', __( 'This proposal is no longer waiting.', 'affstackdev-image-manager' ) );
}
return self::decide( $p, 'rejected', __( 'Rejected by the site owner. Nothing was applied.', 'affstackdev-image-manager' ) );
}
/**
* Save a decision.
*
* @param array $p Proposal.
* @param string $status applied, rejected, stale, failed or unknown.
* @param string $message What happened.
* @param array $state The read-back result (applied only).
* @return array|\WP_Error The proposal's view, or the error for failures.
*/
private static function decide( array $p, string $status, string $message, array $state = array() ) {
$p['status'] = $status;
$p['decided_by'] = get_current_user_id();
$p['decided'] = time();
$p['result'] = array(
'message' => $message,
'state' => $state,
);
self::save( $p );
if ( in_array( $status, array( 'failed', 'stale', 'unknown' ), true ) ) {
return new \WP_Error( 'im_ai_' . $status, $message );
}
return self::view( $p );
}
/**
* The reviewed owner's prepared change for a proposal, as AffStackDev AI's projects hand it over.
*
* @param array $p Proposal.
* @return array
*/
private static function prepared( array $p ): array {
return array(
'snapshot' => array( 'state' => $p['state'] ),
'proposed' => array(
'provider_version' => VERSION,
'contract_version' => Reviewed_Actions::CONTRACT,
'parameters' => $p['parameters'],
'expected_after' => $p['expected_after'],
),
);
}
/**
* The execution context: a marker and project ID of this proposal, and a guard that re-checks the approver.
*
* @param array $p Proposal.
* @return array
*/
private static function context( array $p ): array {
return array(
'guard' => static function () {
return self::can() ? true : new \WP_Error( 'im_ai_permission', __( 'The approving administrator lost access.', 'affstackdev-image-manager' ) );
},
'marker' => 'afsd-im-native:' . hash( 'sha256', $p['id'] . '|' . $p['created'] ),
'project_id' => 'proposal_' . $p['id'],
);
}
/**
* The AI assistants tab's forms: the on/off switch, approve, reject and check again.
*
* @return void
*/
public static function handle(): void {
if ( ! self::can() ) {
wp_die( esc_html__( 'You do not have permission to manage site images.', 'affstackdev-image-manager' ), '', array( 'response' => 403 ) );
}
check_admin_referer( self::ACTION );
$kind = isset( $_POST['kind'] ) ? sanitize_key( wp_unslash( $_POST['kind'] ) ) : '';
$id = isset( $_POST['proposal'] ) ? sanitize_key( wp_unslash( $_POST['proposal'] ) ) : '';
if ( 'settings' === $kind ) {
Settings::patch( array( 'ai_assistants' => ! empty( $_POST['ai_assistants'] ) ) );
$notice = ! empty( $_POST['ai_assistants'] ) ? __( 'AI assistants can use Image Manager.', 'affstackdev-image-manager' ) : __( 'AI assistants can no longer use Image Manager.', 'affstackdev-image-manager' );
} elseif ( 'approve' === $kind ) {
$r = self::approve( $id );
$notice = is_wp_error( $r ) ? $r->get_error_message() : __( 'Approved and applied. Image Manager read the result back.', 'affstackdev-image-manager' );
} elseif ( 'reject' === $kind ) {
$r = self::reject( $id );
$notice = is_wp_error( $r ) ? $r->get_error_message() : __( 'Rejected. Nothing was applied.', 'affstackdev-image-manager' );
} elseif ( 'recheck' === $kind ) {
$r = self::recheck( $id );
$notice = is_wp_error( $r ) ? $r->get_error_message() : __( 'Checked: the change is applied.', 'affstackdev-image-manager' );
} else {
$notice = __( 'Choose an action.', 'affstackdev-image-manager' );
}
set_transient( 'afsd_im_notice_' . get_current_user_id(), $notice, 120 );
wp_safe_redirect( Admin::url( 'ai' ) );
exit;
}
/**
* A small form of the tab.
*
* @param string $kind settings, approve, reject or recheck.
* @param string $id Proposal ID.
* @param string $label Button label.
* @param bool $primary Primary button.
* @param string $extra Extra fields (escaped as they were built; printed through wp_kses()).
* @return void
*/
private static function form( string $kind, string $id, string $label, bool $primary = false, string $extra = '' ): void {
echo '
';
}
/**
* The AI assistants tab.
*
* @return void
*/
public static function render(): void {
$on = ! empty( Settings::get()['ai_assistants'] );
$api = AI_Abilities::api();
$adapter = class_exists( '\WP\MCP\Core\McpAdapter' );
echo '' . esc_html__( 'AI assistants', 'affstackdev-image-manager' ) . ' ';
echo ''
. esc_html__( 'An AI app you connect to this site (Claude, ChatGPT and others that speak MCP, or WordPress\'s own AI features) can read Image Manager and propose changes through WordPress\'s Abilities API. Reads answer at once. Every change waits here until you approve it, and is then applied and read back the same way as a change you make yourself.', 'affstackdev-image-manager' )
. '
';
$checkbox = '' . esc_html__( 'Let AI assistants use Image Manager', 'affstackdev-image-manager' ) . ' ';
self::form( 'settings', '', __( 'Save', 'affstackdev-image-manager' ), false, $checkbox );
echo '';
echo '
' . esc_html__( 'WordPress abilities', 'affstackdev-image-manager' ) . ' '
. esc_html( $api ? ( AI_Abilities::enabled() ? __( 'Offered (category "Image Manager")', 'affstackdev-image-manager' ) : __( 'Not offered: switched off', 'affstackdev-image-manager' ) ) : __( 'This WordPress has no Abilities API (WordPress 6.9 or later has it).', 'affstackdev-image-manager' ) )
. ' ';
echo '
' . esc_html__( 'MCP address', 'affstackdev-image-manager' ) . ' ' . esc_html( $adapter ? rest_url( 'affstackdev-image-manager/mcp' ) : __( 'Needs an MCP adapter on this site (WordPress\'s MCP Adapter plugin, or WooCommerce MCP turned on).', 'affstackdev-image-manager' ) ) . ' ';
echo ' ' . esc_html__( 'To connect an AI app, give it this site\'s address and an Application Password of an administrator (Users > Profile > Application Passwords).', 'affstackdev-image-manager' ) . '
';
$waiting = self::all( 'waiting', 50 );
echo '' . esc_html__( 'Waiting for your approval', 'affstackdev-image-manager' ) . ' ';
if ( ! $waiting ) {
echo '' . esc_html__( 'No change is waiting.', 'affstackdev-image-manager' ) . '
';
}
foreach ( $waiting as $p ) {
echo '' . esc_html( $p['title'] ) . ' ';
/* translators: 1: date and time, 2: how the proposal arrived (rest or mcp). */
echo '' . esc_html( sprintf( __( 'Proposed %1$s through %2$s.', 'affstackdev-image-manager' ), wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) $p['created'] ), strtoupper( $p['via'] ) ) ) . '
';
if ( '' !== $p['note'] ) {
echo '' . esc_html( $p['note'] ) . '
';
}
echo '' . esc_html__( 'What', 'affstackdev-image-manager' ) . ' ' . esc_html__( 'Now', 'affstackdev-image-manager' ) . ' ' . esc_html__( 'After', 'affstackdev-image-manager' ) . ' ';
foreach ( (array) $p['changes'] as $row ) {
echo '' . esc_html( (string) ( $row['label'] ?? '' ) ) . ' ' . esc_html( (string) ( $row['before'] ?? '' ) ) . ' ' . esc_html( (string) ( $row['after'] ?? '' ) ) . ' ';
}
echo '
';
foreach ( (array) $p['effects'] as $effect ) {
echo '' . esc_html( (string) ( $effect['description'] ?? '' ) ) . ' ';
}
echo ' ';
self::form( 'approve', $p['id'], __( 'Approve and apply', 'affstackdev-image-manager' ), true );
self::form( 'reject', $p['id'], __( 'Reject', 'affstackdev-image-manager' ) );
echo '
';
}
echo ' ';
$labels = array(
'applied' => __( 'Applied', 'affstackdev-image-manager' ),
'rejected' => __( 'Rejected', 'affstackdev-image-manager' ),
'stale' => __( 'Not applied: changed meanwhile', 'affstackdev-image-manager' ),
'failed' => __( 'Not applied', 'affstackdev-image-manager' ),
'unknown' => __( 'Result uncertain', 'affstackdev-image-manager' ),
'applying' => __( 'Being applied', 'affstackdev-image-manager' ),
);
$done = array_filter( self::all( '', self::KEEP ), static fn( $p ) => 'waiting' !== $p['status'] );
echo '' . esc_html__( 'Recent decisions', 'affstackdev-image-manager' ) . ' ';
if ( ! $done ) {
echo '' . esc_html__( 'Nothing decided yet.', 'affstackdev-image-manager' ) . '
';
}
foreach ( array_slice( $done, 0, 20 ) as $p ) {
echo '' . esc_html( $p['title'] ) . ' · ' . esc_html( $labels[ $p['status'] ] ?? $p['status'] ) . ' · ' . esc_html( wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), (int) ( $p['decided'] ?? $p['created'] ) ) );
if ( ! empty( $p['result']['message'] ) ) {
echo '
' . esc_html( (string) $p['result']['message'] ) . '
';
}
if ( in_array( $p['status'], array( 'unknown', 'applying' ), true ) ) {
self::form( 'recheck', $p['id'], __( 'Check the result again', 'affstackdev-image-manager' ) );
}
echo '
';
}
echo ' ';
}
}