Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
98ac5ae
Add Suggest Reply feature with AI-generated reply suggestions for com…
Infinite-Null Jun 12, 2026
c27f5f8
feat: add optional editorial guidelines field to reply suggestion req…
Infinite-Null Jun 15, 2026
5cbd7b1
refactor: remove unnecessary useCallback hooks in ReplyModalControlle…
Infinite-Null Jun 15, 2026
1f00b1d
refactor: inline handleSelect callback in ReplyModal to simplify comp…
Infinite-Null Jun 15, 2026
c77c4b9
feat: Enable suggest reply action for unapproved comments and include…
Infinite-Null Jun 15, 2026
f49d77e
refactor: apply consistent spacing and formatting throughout ReplyMod…
Infinite-Null Jun 15, 2026
032de1a
refactor: improve code readability in ReplyModalController by adding …
Infinite-Null Jun 15, 2026
bc26c92
fix: Improve error handling in ReplyModal and update styles to use CS…
Infinite-Null Jun 15, 2026
cb18981
docs: add missing PHPDoc blocks to Suggest Reply ability and experime…
Infinite-Null Jun 16, 2026
0b845e7
docs: add PHPDoc and JSDoc documentation to suggest-reply experiment …
Infinite-Null Jun 16, 2026
7000fb3
style: fix whitespace and formatting in docblock comments and update …
Infinite-Null Jun 16, 2026
f808c0a
Merge branch 'develop' into feat/add-reply-suggestion
Infinite-Null Jun 16, 2026
bbc1d3b
feat: add clipboard copy functionality and implement focus management…
Infinite-Null Jun 16, 2026
6418f19
fix: cast comment post ID to integer and update docblock for row actions
Infinite-Null Jun 16, 2026
b015cb7
refactor: improve docblock type hinting and format Suggest Reply UI c…
Infinite-Null Jun 16, 2026
e968f30
fix: prevent potential memory leaks by clearing focus timeouts in Rep…
Infinite-Null Jun 16, 2026
02471ab
feat: implement suggest reply functionality and add project documenta…
Infinite-Null Jun 16, 2026
ef18471
feat: update experiment description and implement dynamic generate bu…
Infinite-Null Jun 16, 2026
7895525
refactor: remove manual focus management and simplify ReplyModal butt…
Infinite-Null Jun 16, 2026
2f331fd
Merge branch 'develop' into feat/add-reply-suggestion
Infinite-Null Jun 18, 2026
53e9dd1
style: update ReplyModal components to use 40px default size for cons…
Infinite-Null Jun 18, 2026
f0cf86f
feat: document Suggest Reply experiment and update project dependencies
Infinite-Null Jun 23, 2026
02e6d3a
chore: remove suggest reply experiment documentation from `/Experimen…
Infinite-Null Jun 23, 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
150 changes: 150 additions & 0 deletions docs/experiments/suggest-reply.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# Suggest Reply

## Summary

The Suggest Reply experiment adds an AI-powered "Suggest reply" action to the classic Comments admin screen and the Activity widget on the Dashboard. When activated, moderators can generate AI-suggested replies to comments, customize the tone, and provide specific guidelines for the reply. The experiment exposes one WordPress Ability (`ai/reply-suggestion`) that can be used from the UI or via REST API.

## Overview

When enabled, each comment in the Comments list table and the Dashboard Activity widget gets an additional **Suggest reply** action link. Clicking it opens a modal overlay allowing users to generate context-aware replies.

**Key Features:**

- Adds a "Suggest reply" action to comments in the list table and the Dashboard Activity widget
- Provides a modal interface to set the desired Tone (friendly, professional, casual) and optional editorial Guidelines
- Generates a single, relevant reply based on the comment text and parent post context
- Automatically populates the inline WordPress reply form when the generated reply is selected
- Uses one shared ability (`ai/reply-suggestion`) exposed via REST API

### Input Schema

The `ai/reply-suggestion` ability accepts:

```php
array(
'type' => 'object',
'properties' => array(
'comment_id' => array(
'type' => 'integer',
'description' => 'The ID of the comment to generate a reply for.',
'required' => true,
),
'tone' => array(
'type' => 'string',
'enum' => array( 'professional', 'friendly', 'casual' ),
'default' => 'friendly',
'description' => 'The tone for the reply.',
),
'guidelines' => array(
'type' => 'string',
'default' => '',
'description' => 'Optional free-text editorial guidelines to apply when writing the reply.',
),
),
'required' => array( 'comment_id' ),
)
```

### Output Schema

The ability returns:

```php
array(
'type' => 'object',
'properties' => array(
'comment_id' => array(
'type' => 'integer',
'description' => 'The comment ID.',
),
'reply' => array(
'type' => 'string',
'description' => 'The generated reply suggestion.',
),
),
)
```

### Permissions

- `ai/reply-suggestion` requires `current_user_can( 'moderate_comments' )`

## Using the Ability via REST API

### Endpoint

```text
POST /wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/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 Example

```bash
curl -X POST "https://yoursite.com/wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run" \
-u "username:application-password" \
-H "Content-Type: application/json" \
-d '{
"input": {
"comment_id": 1,
"tone": "professional",
"guidelines": "Thank the user for their feedback."
}
}'
```

**Response:**

```json
{
"comment_id": 1,
"reply": "Thank you for your valuable feedback! We appreciate you taking the time to share your thoughts."
}
```

### Error Responses

The ability may return:

- `missing_comment_id`: `comment_id` was not provided
- `comment_not_found`: no comment exists for the given ID
- `insufficient_capabilities`: current user lacks moderation permissions

## Testing

### Manual Testing

1. **Enable the experiment:**
- Go to `Settings -> AI`
- Enable global AI features and toggle **Suggest Reply**
- Ensure valid AI connector credentials are configured

2. **Suggest reply modal:**
- Go to `Comments -> All Comments`
- Hover over an comment and click **Suggest reply**
- Select a Tone, enter Guidelines, and click **Generate**
- Verify that the AI generates a reply
- Click **Use this reply** and verify the inline comment reply textarea is populated with the text

3. **REST API:**
- Call `POST /wp-json/wp-abilities/v1/abilities/ai/reply-suggestion/run` with a valid `comment_id`
- Verify response shape and error handling for invalid IDs or insufficient permissions

## Notes & Considerations

### Requirements

- Requires valid AI credentials and text-generation-capable models
- Requires users with comment moderation capabilities for ability access

### Limitations

- Works on the classic comments list table and the Dashboard Activity widget (no block-based comments UI integration here)
241 changes: 241 additions & 0 deletions includes/Abilities/Suggest_Reply/Reply_Suggestion.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
<?php
/**
* Reply suggestion ability implementation.
*
* @package WordPress\AI
*/

declare( strict_types=1 );

namespace WordPress\AI\Abilities\Suggest_Reply;

use WP_Error;
use WordPress\AI\Abstracts\Abstract_Ability;

use function WordPress\AI\get_preferred_models_for_text_generation;

// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
* Ability that generates AI-powered reply suggestions for a comment.
*
* @since x.x.x
*/
class Reply_Suggestion extends Abstract_Ability {

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function input_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'comment_id' => array(
'type' => 'integer',
'description' => esc_html__( 'The ID of the comment to generate a reply for.', 'ai' ),
),
'tone' => array(
'type' => 'string',
'enum' => array( 'professional', 'friendly', 'casual' ),
'default' => 'friendly',
'description' => esc_html__( 'The tone for the reply.', 'ai' ),
),
'guidelines' => array(
'type' => 'string',
'default' => '',
'description' => esc_html__( 'Optional free-text editorial guidelines to apply when writing the reply.', 'ai' ),
),
),
'required' => array( 'comment_id' ),
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function output_schema(): array {
return array(
'type' => 'object',
'properties' => array(
'comment_id' => array(
'type' => 'integer',
'description' => esc_html__( 'The comment ID.', 'ai' ),
),
'reply' => array(
'type' => 'string',
'description' => esc_html__( 'The generated reply suggestion.', 'ai' ),
),
),
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*
* @return array{comment_id: int, reply: string}|\WP_Error The result of the ability execution.
*/
protected function execute_callback( $input ) {
$input = wp_parse_args(
(array) $input,
array(
'comment_id' => 0,
'tone' => 'friendly',
'guidelines' => '',
)
);

$comment_id = absint( $input['comment_id'] );

if ( ! $comment_id ) {
return new WP_Error(
'missing_comment_id',
esc_html__( 'A comment ID is required.', 'ai' )
);
}

$comment = get_comment( $comment_id );

if ( ! $comment || ! is_a( $comment, '\WP_Comment' ) ) {
return new WP_Error(
'comment_not_found',
sprintf(
/* translators: %d: Comment ID. */
esc_html__( 'Comment with ID %d not found.', 'ai' ),
$comment_id
)
);
}

// Fetch post context.
$post = get_post( (int) $comment->comment_post_ID );
$post_title = $post instanceof \WP_Post ? $post->post_title : '';
$post_excerpt = $post instanceof \WP_Post
? wp_trim_words( wp_strip_all_tags( $post->post_content ), 50 )
: '';

$tone = in_array( $input['tone'], array( 'professional', 'friendly', 'casual' ), true )
? $input['tone']
: 'friendly';
$guidelines = sanitize_textarea_field( (string) $input['guidelines'] );

// Build the prompt context.
$context = $this->build_context( $comment, $post_title, $post_excerpt, $tone, $guidelines );

// Generate the reply.
$reply = $this->generate_reply( $context );

if ( is_wp_error( $reply ) ) {
return $reply;
}

return array(
'comment_id' => $comment_id,
'reply' => $reply,
);
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function permission_callback( $input ) {
if ( ! current_user_can( 'moderate_comments' ) ) {
return new WP_Error(
'insufficient_capabilities',
esc_html__( 'You do not have permission to generate reply suggestions.', 'ai' )
);
}

return true;
}

/**
* {@inheritDoc}
*
* @since x.x.x
*/
protected function meta(): array {
return array(
'show_in_rest' => true,
);
}

/**
* Builds the prompt context string from the comment and post data.
*
* @since x.x.x
*
* @param \WP_Comment $comment The comment to reply to.
* @param string $post_title The title of the parent post.
* @param string $post_excerpt A short excerpt of the parent post content.
* @param string $tone The desired reply tone (e.g. 'friendly', 'professional').
* @param string $guidelines Optional editorial guidelines to apply.
* @return string The assembled prompt context.
*/
private function build_context(
\WP_Comment $comment,
string $post_title,
string $post_excerpt,
string $tone,
string $guidelines = ''
): string {
$parts = array();

if ( '' !== $post_title ) {
$parts[] = sprintf( 'Post Title: %s', $post_title );
}

if ( '' !== $post_excerpt ) {
$parts[] = sprintf( 'Post Context: %s', $post_excerpt );
}

$parts[] = sprintf( 'Comment Author: %s', $comment->comment_author );
$parts[] = sprintf( 'Comment: """%s"""', $comment->comment_content );
$parts[] = sprintf( 'Requested Tone: %s', $tone );

if ( '' !== $guidelines ) {
$parts[] = sprintf( 'Editorial Guidelines: %s', $guidelines );
}

return implode( "\n", $parts );
}

/**
* Generates a reply suggestion via the AI client.
*
* @since x.x.x
*
* @param string $context The assembled prompt context string.
* @return string|\WP_Error The sanitized reply text, or a WP_Error on failure.
*/
private function generate_reply( string $context ) {
$prompt_builder = wp_ai_client_prompt( $context )
->using_system_instruction( $this->get_system_instruction() )
->using_model_preference( ...get_preferred_models_for_text_generation() );

$is_supported = $this->ensure_text_generation_supported(
$prompt_builder,
esc_html__( 'Reply suggestion could not be generated. Please ensure you have a connected provider that supports text generation.', 'ai' )
);

if ( is_wp_error( $is_supported ) ) {
return $is_supported;
}

$result = $prompt_builder->generate_text();

if ( is_wp_error( $result ) ) {
return $result;
}

return sanitize_textarea_field( trim( (string) $result ) );
}
}
Loading
Loading