diff --git a/docs/experiments/suggest-reply.md b/docs/experiments/suggest-reply.md new file mode 100644 index 000000000..acb96a38d --- /dev/null +++ b/docs/experiments/suggest-reply.md @@ -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) diff --git a/includes/Abilities/Suggest_Reply/Reply_Suggestion.php b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php new file mode 100644 index 000000000..f19e97371 --- /dev/null +++ b/includes/Abilities/Suggest_Reply/Reply_Suggestion.php @@ -0,0 +1,241 @@ + '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 ) ); + } +} diff --git a/includes/Abilities/Suggest_Reply/system-instruction.php b/includes/Abilities/Suggest_Reply/system-instruction.php new file mode 100644 index 000000000..89ae3e986 --- /dev/null +++ b/includes/Abilities/Suggest_Reply/system-instruction.php @@ -0,0 +1,29 @@ + __( 'Suggest Reply', 'ai' ), + 'description' => __( 'Adds a "Suggest Reply" action to the Comments screen and Activity widget, enabling moderators to generate and insert AI-powered reply suggestions.', 'ai' ), + 'category' => Experiment_Category::ADMIN, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + + add_filter( 'comment_row_actions', array( $this, 'add_row_action' ), 10, 2 ); + + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers the reply suggestion ability. + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/reply-suggestion', + array( + 'label' => __( 'Reply Suggestion', 'ai' ), + 'description' => __( 'Generates AI-powered reply suggestions for a comment.', 'ai' ), + 'ability_class' => Reply_Suggestion_Ability::class, + ) + ); + } + + /** + * Adds a "Suggest reply" action link to the comment row actions. + * + * @since x.x.x + * + * @param mixed $actions The existing comment row actions. + * @param \WP_Comment $comment The comment object. + * @return array The modified actions array. + */ + public function add_row_action( $actions, $comment ): array { + if ( + ! is_array( $actions ) || + ! $comment || + ! is_a( $comment, '\WP_Comment' ) + ) { + return $actions; + } + + $actions['wpai_suggest_reply'] = sprintf( + '%s', + absint( $comment->comment_ID ), + esc_attr__( 'Suggest a reply for this comment', 'ai' ), + esc_html__( 'Suggest reply', 'ai' ) + ); + + return $actions; + } + + /** + * Enqueues assets for the Suggest Reply experiment. + * + * @since x.x.x + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + if ( ! in_array( $hook_suffix, array( 'edit-comments.php', 'index.php' ), true ) ) { + return; + } + + Asset_Loader::enqueue_script( + 'suggest_reply', + 'experiments/suggest-reply', + array( 'include_core_abilities' => true ) + ); + + Asset_Loader::localize_script( + 'suggest_reply', + 'SuggestReplyData', + array( + 'enabled' => $this->is_enabled(), + 'nonce' => wp_create_nonce( 'wp_rest' ), + ) + ); + } +} diff --git a/src/experiments/suggest-reply/components/ReplyModal.tsx b/src/experiments/suggest-reply/components/ReplyModal.tsx new file mode 100644 index 000000000..1421c1089 --- /dev/null +++ b/src/experiments/suggest-reply/components/ReplyModal.tsx @@ -0,0 +1,223 @@ +/** + * Modal component for the Suggest Reply experiment. + */ + +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { + Button, + Flex, + FlexItem, + Modal, + Notice, + SelectControl, + Spinner, + TextareaControl, +} from '@wordpress/components'; +import { useState, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import { useCopyToClipboardFeedback } from '../../../hooks/use-copy-to-clipboard-feedback'; + +type Tone = 'professional' | 'friendly' | 'casual'; + +type ReplySuggestionResult = { + comment_id: number; + reply: string; +}; + +export type ReplyModalProps = { + commentId: number; + onClose: () => void; + onSelectReply: ( reply: string, commentId: number ) => void; +}; + +/** + * Renders the AI reply suggestion modal, allowing the moderator to choose + * a tone, provide optional guidelines, generate a reply, and insert it. + */ +export function ReplyModal( { + commentId, + onClose, + onSelectReply, +}: ReplyModalProps ): React.ReactElement { + const [ isLoading, setIsLoading ] = useState( false ); + const [ reply, setReply ] = useState< string >( '' ); + const [ tone, setTone ] = useState< Tone >( 'friendly' ); + const [ guidelines, setGuidelines ] = useState< string >( '' ); + const [ error, setError ] = useState< string | null >( null ); + + const { ref: copyRef, hasCopied } = + useCopyToClipboardFeedback< HTMLButtonElement >( { + text: reply, + announcement: __( 'Reply copied to clipboard.', 'ai' ), + } ); + + const generateReply = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + const result = await runAbility< ReplySuggestionResult >( + 'ai/reply-suggestion', + { + comment_id: commentId, + tone, + guidelines, + } + ); + + setReply( result.reply ?? '' ); + } catch ( err: any ) { + setError( + Boolean( err.message ) + ? err.message + : __( 'Failed to generate reply suggestion.', 'ai' ) + ); + } finally { + setIsLoading( false ); + } + }, [ commentId, tone, guidelines ] ); + + const toneOptions = [ + { label: __( 'Friendly', 'ai' ), value: 'friendly' }, + { label: __( 'Professional', 'ai' ), value: 'professional' }, + { label: __( 'Casual', 'ai' ), value: 'casual' }, + ]; + + const getGenerateText = useCallback( () => { + if ( isLoading ) { + return __( 'Generating…', 'ai' ); + } + + if ( error ) { + return __( 'Retry', 'ai' ); + } + + if ( reply ) { + return __( 'Regenerate', 'ai' ); + } + + return __( 'Generate', 'ai' ); + }, [ error, reply, isLoading ] ); + + return ( + + + + setTone( value as Tone ) } + __next40pxDefaultSize + /> + + + { /* Guidelines */ } + + + + + { /* Loading spinner */ } + { isLoading && ( + + + + { __( 'Generating reply suggestion…', 'ai' ) } + + + ) } + + { /* Error notice */ } + { ! isLoading && error && ( + + + { error } + + + ) } + + { /* Generated reply */ } + { ! isLoading && ! error && reply && ( + +

+ { reply } +

+
+ ) } + + { /* Action buttons */ } + + + { reply && ! error && ( + <> + + + + + ) } + +
+
+ ); +} diff --git a/src/experiments/suggest-reply/components/ReplyModalController.tsx b/src/experiments/suggest-reply/components/ReplyModalController.tsx new file mode 100644 index 000000000..43636cbeb --- /dev/null +++ b/src/experiments/suggest-reply/components/ReplyModalController.tsx @@ -0,0 +1,159 @@ +/** + * Controller for the Suggest Reply modal. + */ + +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { useEffect, useState, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ReplyModal } from './ReplyModal'; + +type ModalState = { + isOpen: boolean; + commentId: number | null; +}; + +/** + * Mounts a delegated click listener on the comment list and renders the + * reply modal when a "Suggest reply" action link is clicked. + */ +export function ReplyModalController(): React.ReactElement { + const [ modalState, setModalState ] = useState< ModalState >( { + isOpen: false, + commentId: null, + } ); + + const populateTimeoutRef = useRef< number | null >( null ); + + /** Writes the generated reply into the inline reply textarea and focuses it. */ + const populateReplyTextarea = ( reply: string ) => { + const textarea = document.querySelector< HTMLTextAreaElement >( + '#replycontainer #replycontent' + ); + + if ( ! textarea ) { + return; + } + + textarea.value = reply; + textarea.focus(); + textarea.dispatchEvent( new Event( 'input', { bubbles: true } ) ); + }; + + /** Returns true when the WordPress inline reply form is already open for the given comment. */ + const isInlineReplyOpenForComment = ( commentId: number ): boolean => { + const replyRow = document.querySelector< HTMLElement >( '#replyrow' ); + const commentIdInput = document.querySelector< HTMLInputElement >( + '#replyrow #comment_ID' + ); + + if ( ! replyRow || ! commentIdInput ) { + return false; + } + + const isVisible = + replyRow.style.display !== 'none' && replyRow.offsetParent !== null; + const isForComment = parseInt( commentIdInput.value, 10 ) === commentId; + + return isVisible && isForComment; + }; + + const closeModal = () => + setModalState( ( prev ) => ( { ...prev, isOpen: false } ) ); + + /** + * Closes the modal and inserts the selected reply into the comment reply form. + * Opens the inline reply row first if it is not already visible. + */ + const handleSelectReply = ( reply: string, commentId: number ) => { + closeModal(); + + if ( isInlineReplyOpenForComment( commentId ) ) { + populateReplyTextarea( reply ); + return; + } + + const replyButton = document.querySelector< HTMLButtonElement >( + `#comment-${ commentId } .reply button` + ); + + if ( replyButton ) { + replyButton.click(); + } + + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); + } + + populateTimeoutRef.current = window.setTimeout( () => { + populateReplyTextarea( reply ); + populateTimeoutRef.current = null; + }, 150 ); + }; + + useEffect( () => { + const handleClick = ( event: MouseEvent ) => { + const target = event.target as HTMLElement; + + if ( ! target.classList.contains( 'wpai-suggest-reply' ) ) { + return; + } + + event.preventDefault(); + const commentId = parseInt( + target.getAttribute( 'data-comment-id' ) ?? '0', + 10 + ); + + if ( commentId > 0 ) { + setModalState( { isOpen: true, commentId } ); + } + }; + + const commentList = document.querySelector( '#the-comment-list' ); + + if ( commentList ) { + commentList.addEventListener( + 'click', + handleClick as EventListener + ); + + return () => + commentList.removeEventListener( + 'click', + handleClick as EventListener + ); + } + + return undefined; + }, [] ); + + useEffect( () => { + return () => { + if ( populateTimeoutRef.current !== null ) { + window.clearTimeout( populateTimeoutRef.current ); + } + }; + }, [] ); + + return ( + <> + { modalState.isOpen && modalState.commentId !== null && ( + + ) } + + ); +} diff --git a/src/experiments/suggest-reply/index.tsx b/src/experiments/suggest-reply/index.tsx new file mode 100644 index 000000000..6516e7fe8 --- /dev/null +++ b/src/experiments/suggest-reply/index.tsx @@ -0,0 +1,39 @@ +/** + * Suggest reply experiment plugin registration. + */ + +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ReplyModalController } from './components/ReplyModalController'; + +declare global { + interface Window { + aiSuggestReplyData?: { + enabled: boolean; + nonce: string; + }; + } +} + +function init(): void { + const data = window.aiSuggestReplyData; + + if ( ! data?.enabled ) { + return; + } + + const container = document.createElement( 'div' ); + container.id = 'wpai-suggest-reply-root'; + document.body.appendChild( container ); + + createRoot( container ).render( ); +} + +domReady( init ); diff --git a/webpack.config.js b/webpack.config.js index 454133270..a31b1b8ae 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -100,6 +100,11 @@ module.exports = { 'src/experiments/comment-moderation', 'index.tsx' ), + 'experiments/suggest-reply': path.resolve( + process.cwd(), + 'src/experiments/suggest-reply', + 'index.tsx' + ), 'experiments/alt-text-generation': path.resolve( process.cwd(), 'src/experiments/alt-text-generation',