diff --git a/.wordpress-org/screenshot-#.png b/.wordpress-org/screenshot-#.png new file mode 100644 index 00000000..823fbd47 Binary files /dev/null and b/.wordpress-org/screenshot-#.png differ diff --git a/docs/experiments/comment-moderation.md b/docs/experiments/comment-moderation.md new file mode 100644 index 00000000..fe8b77b6 --- /dev/null +++ b/docs/experiments/comment-moderation.md @@ -0,0 +1,34 @@ +# Comment Moderation + +## Summary +Adds AI-powered sentiment analysis, toxicity scoring, and reply suggestions to the classic Comments screen. Moderators can see badges directly in `edit-comments.php`, run bulk analysis, and request suggested replies without leaving wp-admin. + +## Key Hooks & Entry Points +- `WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::register()` wires everything once the experiment is enabled: + - `wp_abilities_api_init` → registers `ai/comment-analysis` and `ai/reply-suggestion` abilities (`includes/Abilities/Comment_Moderation/*.php`). + - `manage_edit-comments_columns`, `manage_comments_custom_column` → inject sentiment/toxicity columns. + - `bulk_actions-edit-comments`, `handle_bulk_actions-edit-comments`, `admin_notices` → add the “Analyze with AI” bulk flow and status notices. + - `comment_row_actions` → adds the “AI Reply” row action. + - `admin_enqueue_scripts` → enqueues the React bundle on `edit-comments.php`. + - `admin_head-edit-comments.php` → prints inline badge styles so they render even when JS fails. +- REST and comment-meta updates happen via the two abilities; the experiment itself only orchestrates UI + enqueue points. + +## Assets & Data Flow +1. `enqueue_assets()` loads `experiments/comment-moderation` (`src/experiments/comment-moderation/index.tsx`) and localizes `window.CommentModerationData` with `enabled` + nonce. +2. The React entry mounts two controllers: + - `LazyAnalysisController` polls for comments that need analysis and calls `runAbility( 'ai/comment-analysis' )`, updating comment meta and refreshing rows in place. + - `ReplyModalController` opens a modal when an “AI Reply” row action is clicked, calling `runAbility( 'ai/reply-suggestion' )` to fetch draft replies the moderator can paste. +3. Both controllers rely on the shared `run-ability.ts` helper so they can use the Abilities API client when available or fall back to REST calls. +4. Ability responses are persisted via comment meta (`_ai_toxicity_score`, `_ai_sentiment`, `_ai_analysis_status`, `_ai_analyzed_at`), which the PHP column renderers read to display badges. + +## Testing +1. Enable Experiments globally and toggle **Comment Moderation** under `Settings → AI Experiments`. +2. Visit `Comments → All Comments`. Pending comments should show “Analyze with AI” badges; clicking one should enqueue an analysis request and update the badge once complete. +3. Select multiple comments, choose the “Analyze with AI” bulk action, and confirm the inline notice reports how many were queued. +4. Approve a comment and click its “AI Reply” row action. The modal should display suggested replies; applying one should copy it into the WordPress reply form. +5. Toggle the experiment off and reload the page—columns, badges, row/bulk actions, and scripts should disappear. + +## Notes +- The experiment only runs for users with `moderate_comments`. +- Analysis locks each comment while it is processing to prevent duplicate requests. +- Replies and analysis rely on AI credentials; without valid credentials the whole experiment remains disabled via the shared experiment toggle logic. diff --git a/includes/Abilities/Comment_Moderation/Comment_Analysis.php b/includes/Abilities/Comment_Moderation/Comment_Analysis.php new file mode 100644 index 00000000..b6d7d193 --- /dev/null +++ b/includes/Abilities/Comment_Moderation/Comment_Analysis.php @@ -0,0 +1,226 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'The ID of the comment to analyze.', 'ai' ), + 'required' => true, + ), + ), + 'required' => array( 'comment_id' ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The analyzed comment ID.', 'ai' ), + ), + 'toxicity_score' => array( + 'type' => 'number', + 'minimum' => 0, + 'maximum' => 1, + 'description' => esc_html__( 'Toxicity score from 0 (not toxic) to 1 (highly toxic).', 'ai' ), + ), + 'sentiment' => array( + 'type' => 'string', + 'enum' => array( 'positive', 'negative', 'neutral' ), + 'description' => esc_html__( 'The sentiment of the comment.', 'ai' ), + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return array{comment_id: int, toxicity_score: float, sentiment: string}|\WP_Error The result of the ability execution. + */ + protected function execute_callback( $input ) { + $comment_id = absint( $input['comment_id'] ?? 0 ); + + if ( ! $comment_id ) { + return new WP_Error( + 'missing_comment_id', + esc_html__( 'Comment ID is required.', 'ai' ) + ); + } + + $comment = get_comment( $comment_id ); + + if ( ! $comment ) { + return new WP_Error( + 'comment_not_found', + sprintf( + /* translators: %d: Comment ID. */ + esc_html__( 'Comment with ID %d not found.', 'ai' ), + $comment_id + ) + ); + } + + // Check if already being processed (lock mechanism). + $current_status = get_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, true ); + + if ( Comment_Moderation::STATUS_PROCESSING === $current_status ) { + return new WP_Error( + 'already_processing', + esc_html__( 'This comment is already being analyzed.', 'ai' ) + ); + } + + // Set status to processing. + update_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, Comment_Moderation::STATUS_PROCESSING ); + + // Analyze the comment. + $result = $this->analyze_comment( $comment->comment_content, $comment->comment_author ); + + if ( is_wp_error( $result ) ) { + // Mark as failed. + update_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, Comment_Moderation::STATUS_FAILED ); + return $result; + } + + // Store the results. + update_comment_meta( $comment_id, Comment_Moderation::META_TOXICITY_SCORE, $result['toxicity_score'] ); + update_comment_meta( $comment_id, Comment_Moderation::META_SENTIMENT, $result['sentiment'] ); + update_comment_meta( $comment_id, Comment_Moderation::META_ANALYSIS_STATUS, Comment_Moderation::STATUS_COMPLETE ); + update_comment_meta( $comment_id, Comment_Moderation::META_ANALYZED_AT, time() ); + + return array( + 'comment_id' => $comment_id, + 'toxicity_score' => $result['toxicity_score'], + 'sentiment' => $result['sentiment'], + ); + } + + /** + * Returns the permission callback of the ability. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + 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 analyze comments.', 'ai' ) + ); + } + + return true; + } + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Analyzes a comment for toxicity and sentiment. + * + * @since 0.1.0 + * + * @param string $content The comment content. + * @param string $author The comment author name. + * @return array{toxicity_score: float, sentiment: string}|\WP_Error The analysis result. + */ + private function analyze_comment( string $content, string $author ) { + $prompt = sprintf( + "Comment by %s:\n\"\"\"%s\"\"\"", + $author, + $content + ); + + $result = AI_Client::prompt_with_wp_error( $prompt ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_model_preference( ...$this->get_model_preferences() ) + ->generate_text(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Parse the JSON response. + $parsed = json_decode( $result, true ); + + if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $parsed ) ) { + return new WP_Error( + 'parse_error', + esc_html__( 'Failed to parse AI response.', 'ai' ) + ); + } + + // Validate and sanitize the response. + $toxicity_score = isset( $parsed['toxicity_score'] ) + ? max( 0, min( 1, (float) $parsed['toxicity_score'] ) ) + : 0; + + $valid_sentiments = array( 'positive', 'negative', 'neutral' ); + $sentiment = isset( $parsed['sentiment'] ) && in_array( $parsed['sentiment'], $valid_sentiments, true ) + ? $parsed['sentiment'] + : 'neutral'; + + return array( + 'toxicity_score' => $toxicity_score, + 'sentiment' => $sentiment, + ); + } +} diff --git a/includes/Abilities/Comment_Moderation/Reply_Suggestion.php b/includes/Abilities/Comment_Moderation/Reply_Suggestion.php new file mode 100644 index 00000000..a1fa11d7 --- /dev/null +++ b/includes/Abilities/Comment_Moderation/Reply_Suggestion.php @@ -0,0 +1,266 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'The ID of the comment to generate replies for.', 'ai' ), + 'required' => true, + ), + 'candidates' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 5, + 'default' => self::CANDIDATES_DEFAULT, + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Number of reply suggestions to generate.', 'ai' ), + ), + 'tone' => array( + 'type' => 'string', + 'enum' => array( 'professional', 'friendly', 'casual' ), + 'default' => 'friendly', + 'sanitize_callback' => 'sanitize_text_field', + 'description' => esc_html__( 'The tone for the reply suggestions.', 'ai' ), + ), + ), + 'required' => array( 'comment_id' ), + ); + } + + /** + * Returns the output schema of the ability. + * + * @since 0.1.0 + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'comment_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'The comment ID.', 'ai' ), + ), + 'replies' => array( + 'type' => 'array', + 'description' => esc_html__( 'Generated reply suggestions.', 'ai' ), + 'items' => array( + 'type' => 'string', + ), + ), + ), + ); + } + + /** + * Executes the ability with the given input arguments. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return array{comment_id: int, replies: array}|\WP_Error The result of the ability execution. + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'comment_id' => 0, + 'candidates' => self::CANDIDATES_DEFAULT, + 'tone' => 'friendly', + ) + ); + + $comment_id = absint( $args['comment_id'] ); + + if ( ! $comment_id ) { + return new WP_Error( + 'missing_comment_id', + esc_html__( 'Comment ID is required.', 'ai' ) + ); + } + + $comment = get_comment( $comment_id ); + + if ( ! $comment ) { + return new WP_Error( + 'comment_not_found', + sprintf( + /* translators: %d: Comment ID. */ + esc_html__( 'Comment with ID %d not found.', 'ai' ), + $comment_id + ) + ); + } + + // Get the post for context. + $post = get_post( $comment->comment_post_ID ); + $post_title = $post ? $post->post_title : ''; + $post_excerpt = $post ? wp_trim_words( wp_strip_all_tags( $post->post_content ), 50 ) : ''; + + // Build context for the AI. + $context = $this->build_context( $comment, $post_title, $post_excerpt, $args['tone'] ); + + // Generate replies. + $result = $this->generate_replies( $context, (int) $args['candidates'] ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + return array( + 'comment_id' => $comment_id, + 'replies' => $result, + ); + } + + /** + * Returns the permission callback of the ability. + * + * @since 0.1.0 + * + * @param mixed $input The input arguments to the ability. + * @return bool|\WP_Error True if the user has permission, WP_Error otherwise. + */ + 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; + } + + /** + * Returns the meta of the ability. + * + * @since 0.1.0 + * + * @return array The meta of the ability. + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Builds the context string for the AI prompt. + * + * @since 0.1.0 + * + * @param \WP_Comment $comment The comment object. + * @param string $post_title The post title. + * @param string $post_excerpt The post excerpt. + * @param string $tone The desired tone. + * @return string The context string. + */ + private function build_context( \WP_Comment $comment, string $post_title, string $post_excerpt, string $tone ): string { + $context_parts = array(); + + if ( $post_title ) { + $context_parts[] = sprintf( 'Post Title: %s', $post_title ); + } + + if ( $post_excerpt ) { + $context_parts[] = sprintf( 'Post Excerpt: %s', $post_excerpt ); + } + + $context_parts[] = sprintf( 'Comment Author: %s', $comment->comment_author ); + $context_parts[] = sprintf( 'Comment: """%s"""', $comment->comment_content ); + $context_parts[] = sprintf( 'Desired Tone: %s', $tone ); + + return implode( "\n", $context_parts ); + } + + /** + * Generates reply suggestions using AI. + * + * @since 0.1.0 + * + * @param string $context The context for generating replies. + * @param int $candidates The number of suggestions to generate. + * @return array|\WP_Error The generated replies or error. + */ + private function generate_replies( string $context, int $candidates ) { + $result = AI_Client::prompt_with_wp_error( $context ) + ->using_system_instruction( $this->get_system_instruction( 'reply-system-instruction.php' ) ) + ->using_candidate_count( max( 1, $candidates ) ) + ->using_model_preference( ...$this->get_model_preferences() ) + ->generate_texts(); + + if ( is_wp_error( $result ) ) { + return $result; + } + + // Clean up the responses. + return array_map( + static function ( $reply ) { + return sanitize_textarea_field( trim( $reply ) ); + }, + $result + ); + } + + /** + * {@inheritDoc} + */ + protected function get_model_preferences(): array { + $openai_preferences = array( + array( 'openai', 'gpt-4.1' ), + array( 'openai', 'gpt-4o-mini' ), + ); + + $remaining = array_filter( + parent::get_model_preferences(), + static function ( array $model ): bool { + return 'openai' !== $model[0]; + } + ); + + return array_merge( $openai_preferences, $remaining ); + } +} diff --git a/includes/Abilities/Comment_Moderation/reply-system-instruction.php b/includes/Abilities/Comment_Moderation/reply-system-instruction.php new file mode 100644 index 00000000..2b0960cf --- /dev/null +++ b/includes/Abilities/Comment_Moderation/reply-system-instruction.php @@ -0,0 +1,24 @@ + The preferred models as [provider, model] pairs. + */ + protected function get_model_preferences(): array { + return get_preferred_models_for_text_generation(); + } + /** * Gets the system instruction for the feature. * diff --git a/includes/Experiment_Loader.php b/includes/Experiment_Loader.php index eef246ac..fb57caf7 100644 --- a/includes/Experiment_Loader.php +++ b/includes/Experiment_Loader.php @@ -106,6 +106,7 @@ private function get_default_experiments(): array { $experiment_classes = array( \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, + \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, \WordPress\AI\Experiments\Image_Generation\Image_Generation::class, \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, diff --git a/includes/Experiments/Comment_Moderation/Comment_Moderation.php b/includes/Experiments/Comment_Moderation/Comment_Moderation.php new file mode 100644 index 00000000..2ebb803c --- /dev/null +++ b/includes/Experiments/Comment_Moderation/Comment_Moderation.php @@ -0,0 +1,562 @@ + 'comment-moderation', + 'label' => __( 'Comment Moderation', 'ai' ), + 'description' => __( 'AI-powered comment analysis with toxicity detection, sentiment analysis, and reply suggestions.', 'ai' ), + ); + } + + /** + * {@inheritDoc} + * + * @since 0.1.0 + */ + public function register(): void { + // Register abilities. + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + + // Add columns to comments list table. + add_filter( 'manage_edit-comments_columns', array( $this, 'add_columns' ) ); + add_action( 'manage_comments_custom_column', array( $this, 'render_column' ), 10, 2 ); + + // Add row action for suggest reply. + add_filter( 'comment_row_actions', array( $this, 'add_row_actions' ), 10, 2 ); + + // Add bulk action. + add_filter( 'bulk_actions-edit-comments', array( $this, 'add_bulk_actions' ) ); + add_filter( 'handle_bulk_actions-edit-comments', array( $this, 'handle_bulk_action' ), 10, 3 ); + add_action( 'admin_notices', array( $this, 'show_bulk_action_notice' ) ); + + // Enqueue assets. + add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); + + // Add inline styles for badges. + add_action( 'admin_head-edit-comments.php', array( $this, 'add_inline_styles' ) ); + } + + /** + * Registers the comment moderation abilities. + * + * @since 0.1.0 + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/comment-analysis', + array( + 'label' => __( 'Comment Analysis', 'ai' ), + 'description' => __( 'Analyzes a comment for toxicity and sentiment.', 'ai' ), + 'ability_class' => Comment_Analysis::class, + ) + ); + + wp_register_ability( + 'ai/reply-suggestion', + array( + 'label' => __( 'Reply Suggestion', 'ai' ), + 'description' => __( 'Generates reply suggestions for a comment.', 'ai' ), + 'ability_class' => Reply_Suggestion::class, + ) + ); + } + + /** + * Adds custom columns to the comments list table. + * + * @since 0.1.0 + * + * @param array $columns The existing columns. + * @return array The modified columns. + */ + public function add_columns( array $columns ): array { + $new_columns = array(); + + foreach ( $columns as $key => $value ) { + $new_columns[ $key ] = $value; + + // Insert our columns after the 'author' column. + if ( 'author' !== $key ) { + continue; + } + + $new_columns['ai_sentiment'] = __( 'Sentiment', 'ai' ); + $new_columns['ai_toxicity'] = __( 'Toxicity', 'ai' ); + } + + return $new_columns; + } + + /** + * Renders the custom column content. + * + * @since 0.1.0 + * + * @param string $column_name The column name. + * @param int $comment_id The comment ID. + */ + public function render_column( string $column_name, int $comment_id ): void { + $status = get_comment_meta( $comment_id, self::META_ANALYSIS_STATUS, true ); + + if ( 'ai_sentiment' === $column_name ) { + $this->render_sentiment_column( $comment_id, $status ); + } elseif ( 'ai_toxicity' === $column_name ) { + $this->render_toxicity_column( $comment_id, $status ); + } + } + + /** + * Renders the sentiment column content. + * + * @since 0.1.0 + * + * @param int $comment_id The comment ID. + * @param string $status The analysis status. + */ + private function render_sentiment_column( int $comment_id, string $status ): void { + if ( self::STATUS_COMPLETE === $status ) { + $sentiment = get_comment_meta( $comment_id, self::META_SENTIMENT, true ); + $this->render_sentiment_badge( $sentiment ); + } elseif ( self::STATUS_PENDING === $status ) { + $this->render_pending_badge( $comment_id ); + } elseif ( self::STATUS_PROCESSING === $status ) { + $this->render_processing_badge( $comment_id ); + } else { + // Empty or not analyzed - show dash. + echo ''; + } + } + + /** + * Renders the toxicity column content. + * + * @since 0.1.0 + * + * @param int $comment_id The comment ID. + * @param string $status The analysis status. + */ + private function render_toxicity_column( int $comment_id, string $status ): void { + if ( self::STATUS_COMPLETE === $status ) { + $score = (float) get_comment_meta( $comment_id, self::META_TOXICITY_SCORE, true ); + $this->render_toxicity_badge( $score ); + } elseif ( self::STATUS_PENDING === $status ) { + $this->render_pending_badge( $comment_id ); + } elseif ( self::STATUS_PROCESSING === $status ) { + $this->render_processing_badge( $comment_id ); + } else { + // Empty or not analyzed - show dash. + echo ''; + } + } + + /** + * Renders a sentiment badge. + * + * @since 0.1.0 + * + * @param string $sentiment The sentiment value. + */ + private function render_sentiment_badge( string $sentiment ): void { + $badges = array( + 'positive' => array( + 'label' => __( 'Positive', 'ai' ), + 'class' => 'ai-badge--positive', + 'icon' => '😊', + ), + 'negative' => array( + 'label' => __( 'Negative', 'ai' ), + 'class' => 'ai-badge--negative', + 'icon' => '😟', + ), + 'neutral' => array( + 'label' => __( 'Neutral', 'ai' ), + 'class' => 'ai-badge--neutral', + 'icon' => '😐', + ), + ); + + $badge = $badges[ $sentiment ] ?? $badges['neutral']; + + printf( + '%s %s', + esc_attr( $badge['class'] ), + esc_attr( $badge['label'] ), + esc_html( $badge['icon'] ), + esc_html( $badge['label'] ) + ); + } + + /** + * Renders a toxicity badge. + * + * @since 0.1.0 + * + * @param float $score The toxicity score (0-1). + */ + private function render_toxicity_badge( float $score ): void { + if ( $score >= 0.7 ) { + $label = __( 'High', 'ai' ); + $class = 'ai-badge--high-toxicity'; + $icon = '⚠️'; + } elseif ( $score >= 0.4 ) { + $label = __( 'Medium', 'ai' ); + $class = 'ai-badge--medium-toxicity'; + $icon = '⚡'; + } else { + $label = __( 'Low', 'ai' ); + $class = 'ai-badge--low-toxicity'; + $icon = '✓'; + } + + printf( + '%s %s', + esc_attr( $class ), + esc_attr( $label ), + absint( $score * 100 ), + esc_html( $icon ), + esc_html( $label ) + ); + } + + /** + * Renders a pending badge for comments queued for analysis. + * + * @since 0.1.0 + * + * @param int $comment_id The comment ID. + */ + private function render_pending_badge( int $comment_id ): void { + printf( + '%s', + absint( $comment_id ), + esc_html__( 'Queued', 'ai' ) + ); + } + + /** + * Renders a processing badge. + * + * @since 0.1.0 + * + * @param int $comment_id The comment ID. + */ + private function render_processing_badge( int $comment_id ): void { + printf( + '%s', + absint( $comment_id ), + esc_html__( 'Analyzing...', 'ai' ) + ); + } + + /** + * Adds bulk actions to the comments list. + * + * @since 0.1.0 + * + * @param array $actions The existing bulk actions. + * @return array The modified bulk actions. + */ + public function add_bulk_actions( array $actions ): array { + $actions['ai_analyze'] = __( 'Analyze with AI', 'ai' ); + return $actions; + } + + /** + * Handles the bulk action for AI analysis. + * + * @since 0.1.0 + * + * @param string $redirect_url The redirect URL. + * @param string $action The action being performed. + * @param array $comment_ids The comment IDs. + * @return string The modified redirect URL. + */ + public function handle_bulk_action( string $redirect_url, string $action, array $comment_ids ): string { + if ( 'ai_analyze' !== $action ) { + return $redirect_url; + } + + // Mark selected comments as pending for analysis. + $queued = 0; + foreach ( $comment_ids as $comment_id ) { + $comment_id = absint( $comment_id ); + if ( ! $comment_id ) { + continue; + } + + update_comment_meta( $comment_id, self::META_ANALYSIS_STATUS, self::STATUS_PENDING ); + ++$queued; + } + + // Add query arg to show notice. + return add_query_arg( 'ai_analysis_queued', $queued, $redirect_url ); + } + + /** + * Shows admin notice after bulk action. + * + * @since 0.1.0 + */ + public function show_bulk_action_notice(): void { + if ( ! isset( $_GET['ai_analysis_queued'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return; + } + + $count = absint( $_GET['ai_analysis_queued'] ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended + + if ( $count <= 0 ) { + return; + } + + printf( + '

%s

', + esc_html( + sprintf( + /* translators: %d: Number of comments queued for analysis. */ + _n( + '%d comment queued for AI analysis.', + '%d comments queued for AI analysis.', + $count, + 'ai' + ), + $count + ) + ) + ); + } + + /** + * Adds row actions to the comments list. + * + * @since 0.1.0 + * + * @param array $actions The existing actions. + * @param \WP_Comment $comment The comment object. + * @return array The modified actions. + */ + public function add_row_actions( array $actions, \WP_Comment $comment ): array { + // Only show for approved comments. + if ( '1' !== $comment->comment_approved ) { + return $actions; + } + + $actions['ai_suggest_reply'] = sprintf( + '%s', + absint( $comment->comment_ID ), + esc_html__( 'AI Reply', 'ai' ) + ); + + return $actions; + } + + /** + * Enqueues admin assets for the comments screen. + * + * @since 0.1.0 + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_assets( string $hook_suffix ): void { + if ( 'edit-comments.php' !== $hook_suffix ) { + return; + } + + Asset_Loader::enqueue_script( 'comment_moderation', 'experiments/comment-moderation' ); + Asset_Loader::localize_script( + 'comment_moderation', + 'CommentModerationData', + array( + 'enabled' => $this->is_enabled(), + 'nonce' => wp_create_nonce( 'ai_comment_moderation' ), + ) + ); + + // Enqueue WordPress components styles. + wp_enqueue_style( 'wp-components' ); + } + + /** + * Adds inline styles for the comment moderation badges. + * + * @since 0.1.0 + */ + public function add_inline_styles(): void { + ?> + + __( 'Try', 'ai' ), + 'url' => admin_url( 'edit-comments.php' ), + 'type' => 'try', + ), + ); + } +} diff --git a/readme.txt b/readme.txt index c497cebc..40085b8a 100644 --- a/readme.txt +++ b/readme.txt @@ -19,6 +19,7 @@ This plugin is built on the [AI Building Blocks for WordPress](https://make.word **Current Features:** * **Title Generation** - Generate title suggestions for your posts with a single click. Perfect for brainstorming headlines or finding the right tone for your content. +* **Comment Moderation** - Analysis of comment toxicity and sentiment, bulk moderation features, and contextual AI reply suggestions in the Comments admin screen. * **Excerpt Generation** - Automatically create concise summaries for your posts. * **Experiment Framework** - Opt-in system that lets you enable only the AI features you want to use. * **Multi-Provider Support** - Works with popular AI providers like OpenAI, Google, and Anthropic. @@ -115,6 +116,7 @@ You can ask questions in the [#core-ai channel on WordPress Slack](https://wordp 1. Post editor showing (Re-)Generate button above the post title field and title recommendations in a modal. 2. AI Experiments settings screen showing toggles to enable specific experiments. 3. AI Credentials settings screen showing API key fields for available AI service providers. +#. Comments admin screen showing AI-powered comment moderation features, including color-coded badges for toxicity scoring and comment sentiment. == Changelog == diff --git a/src/experiments/comment-moderation/components/LazyAnalysisController.tsx b/src/experiments/comment-moderation/components/LazyAnalysisController.tsx new file mode 100644 index 00000000..7405cf44 --- /dev/null +++ b/src/experiments/comment-moderation/components/LazyAnalysisController.tsx @@ -0,0 +1,244 @@ +/** + * Lazy Analysis Controller component. + * + * Detects pending comments in the list table and triggers analysis on-demand. + */ + +/** + * External dependencies + */ +import type React from 'react'; + +/** + * WordPress dependencies + */ +import { useEffect, useState, useCallback } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; + +type AnalysisResult = { + comment_id: number; + toxicity_score: number; + sentiment: 'positive' | 'negative' | 'neutral'; +}; + +type PendingComment = { + id: number; + sentimentBadge: HTMLElement; + toxicityBadge: HTMLElement; +}; + +/** + * Gets the toxicity label and class from score. + */ +function getToxicityDisplay( score: number ): { + label: string; + className: string; + icon: string; +} { + if ( score >= 0.7 ) { + return { + label: 'High', + className: 'ai-badge--high-toxicity', + icon: '⚠️', + }; + } + if ( score >= 0.4 ) { + return { + label: 'Medium', + className: 'ai-badge--medium-toxicity', + icon: '⚡', + }; + } + return { label: 'Low', className: 'ai-badge--low-toxicity', icon: '✓' }; +} + +/** + * Gets the sentiment display info. + */ +function getSentimentDisplay( sentiment: string ): { + label: string; + className: string; + icon: string; +} { + const displays: Record< + string, + { label: string; className: string; icon: string } + > = { + positive: { + label: 'Positive', + className: 'ai-badge--positive', + icon: '😊', + }, + negative: { + label: 'Negative', + className: 'ai-badge--negative', + icon: '😟', + }, + neutral: { + label: 'Neutral', + className: 'ai-badge--neutral', + icon: '😐', + }, + }; + return displays[ sentiment ] || displays.neutral; +} + +/** + * Updates the badge elements with analysis results. + */ +function updateBadges( comment: PendingComment, result: AnalysisResult ): void { + const sentimentDisplay = getSentimentDisplay( result.sentiment ); + const toxicityDisplay = getToxicityDisplay( result.toxicity_score ); + + // Update sentiment badge. + comment.sentimentBadge.className = `ai-badge ${ sentimentDisplay.className }`; + comment.sentimentBadge.textContent = `${ sentimentDisplay.icon } ${ sentimentDisplay.label }`; + comment.sentimentBadge.title = sentimentDisplay.label; + comment.sentimentBadge.removeAttribute( 'data-ai-status' ); + + // Update toxicity badge. + comment.toxicityBadge.className = `ai-badge ${ toxicityDisplay.className }`; + comment.toxicityBadge.textContent = `${ toxicityDisplay.icon } ${ toxicityDisplay.label }`; + comment.toxicityBadge.title = `${ toxicityDisplay.label } (${ Math.round( + result.toxicity_score * 100 + ) }%)`; +} + +/** + * Marks a badge as failed. + */ +function markBadgeFailed( badge: HTMLElement ): void { + badge.className = 'ai-badge ai-badge--failed'; + badge.textContent = 'Failed'; + badge.setAttribute( 'data-ai-status', 'failed' ); +} + +/** + * Marks a badge as processing. + */ +function markBadgeProcessing( badge: HTMLElement ): void { + badge.className = 'ai-badge ai-badge--processing'; + badge.textContent = 'Analyzing...'; + badge.setAttribute( 'data-ai-status', 'processing' ); +} + +/** + * LazyAnalysisController component. + * + * Handles lazy loading of comment analysis when comments are viewed. + */ +export function LazyAnalysisController(): React.ReactElement | null { + const [ isAnalyzing, setIsAnalyzing ] = useState( false ); + + /** + * Finds all pending comments in the DOM. + */ + const findPendingComments = useCallback( (): PendingComment[] => { + const pendingBadges = document.querySelectorAll< HTMLElement >( + '[data-ai-status="pending"], [data-ai-status="failed"]' + ); + + const commentMap = new Map< number, Partial< PendingComment > >(); + + pendingBadges.forEach( ( badge ) => { + const commentId = parseInt( badge.dataset.commentId || '0', 10 ); + if ( ! commentId ) { + return; + } + + if ( ! commentMap.has( commentId ) ) { + commentMap.set( commentId, { id: commentId } ); + } + + const entry = commentMap.get( commentId )!; + + // Determine which column this badge is in. + const cell = badge.closest( 'td' ); + if ( cell?.classList.contains( 'column-ai_sentiment' ) ) { + entry.sentimentBadge = badge; + } else if ( cell?.classList.contains( 'column-ai_toxicity' ) ) { + entry.toxicityBadge = badge; + } + } ); + + // Only return comments that have both badges. + return Array.from( commentMap.values() ).filter( + ( c ): c is PendingComment => + c.sentimentBadge !== undefined && c.toxicityBadge !== undefined + ); + }, [] ); + + /** + * Analyzes a single comment. + */ + const analyzeComment = useCallback( + async ( comment: PendingComment ): Promise< void > => { + // Mark as processing. + markBadgeProcessing( comment.sentimentBadge ); + markBadgeProcessing( comment.toxicityBadge ); + + try { + const result = await runAbility< AnalysisResult >( + 'ai/comment-analysis', + { + comment_id: comment.id, + } + ); + + updateBadges( comment, result ); + } catch ( error ) { + // eslint-disable-next-line no-console + console.error( + `Failed to analyze comment ${ comment.id }:`, + error + ); + markBadgeFailed( comment.sentimentBadge ); + markBadgeFailed( comment.toxicityBadge ); + } + }, + [] + ); + + /** + * Processes all pending comments sequentially. + */ + const processPendingComments = useCallback( async (): Promise< void > => { + if ( isAnalyzing ) { + return; + } + + const pendingComments = findPendingComments(); + + if ( pendingComments.length === 0 ) { + return; + } + + setIsAnalyzing( true ); + + // Process comments one at a time to avoid overwhelming the server. + for ( const comment of pendingComments ) { + await analyzeComment( comment ); + } + + setIsAnalyzing( false ); + }, [ isAnalyzing, findPendingComments, analyzeComment ] ); + + /** + * Initialize analysis on mount. + */ + useEffect( () => { + // Small delay to ensure DOM is fully rendered. + const timeoutId = setTimeout( () => { + processPendingComments(); + }, 500 ); + + return () => clearTimeout( timeoutId ); + }, [ processPendingComments ] ); + + // This component doesn't render anything visible. + return null; +} diff --git a/src/experiments/comment-moderation/components/ReplyModal.tsx b/src/experiments/comment-moderation/components/ReplyModal.tsx new file mode 100644 index 00000000..8928775f --- /dev/null +++ b/src/experiments/comment-moderation/components/ReplyModal.tsx @@ -0,0 +1,277 @@ +/** + * Reply Modal component. + * + * Displays AI-generated reply suggestions in a modal dialog. + */ + +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { + Button, + Flex, + FlexItem, + Modal, + SelectControl, + Spinner, + TextareaControl, +} from '@wordpress/components'; +import { useState, useEffect, useCallback } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; + +type Tone = 'professional' | 'friendly' | 'casual'; + +type ReplySuggestionResult = { + comment_id: number; + replies: string[]; +}; + +type CachedReplies = { + replies: string[]; + tone: Tone; +}; + +type ReplyModalProps = { + commentId: number; + onClose: () => void; + onSelectReply: ( reply: string, commentId: number ) => void; + initialReplies?: CachedReplies; + onRepliesChange?: ( data: CachedReplies ) => void; +}; + +/** + * Single reply option component. + */ +function ReplyOption( { + reply, + index, + onChange, + onSelect, +}: { + reply: string; + index: number; + onChange: ( value: string ) => void; + onSelect: ( reply: string, index: number ) => void; +} ): React.ReactElement { + return ( + + + + + ); +} + +/** + * ReplyModal component. + * + * Shows a modal with AI-generated reply suggestions. + */ +export function ReplyModal( { + commentId, + onClose, + onSelectReply, + initialReplies, + onRepliesChange, +}: ReplyModalProps ): React.ReactElement { + const [ isLoading, setIsLoading ] = useState( false ); + const [ replies, setReplies ] = useState< string[] >( + initialReplies?.replies ?? [] + ); + const [ tone, setTone ] = useState< Tone >( + initialReplies?.tone ?? 'friendly' + ); + const [ error, setError ] = useState< string | null >( null ); + const [ hasGenerated, setHasGenerated ] = useState( + !! initialReplies?.replies?.length + ); + + /** + * Generates reply suggestions. + */ + const generateReplies = useCallback( async () => { + setIsLoading( true ); + setError( null ); + + try { + const result = await runAbility< ReplySuggestionResult >( + 'ai/reply-suggestion', + { + comment_id: commentId, + tone, + candidates: 3, + } + ); + + setReplies( result.replies ); + setHasGenerated( true ); + + // Notify parent of new replies for caching. + onRepliesChange?.( { replies: result.replies, tone } ); + } catch ( err ) { + const message = + err instanceof Error + ? err.message + : __( 'Failed to generate replies.', 'ai' ); + setError( message ); + } finally { + setIsLoading( false ); + } + }, [ commentId, tone, onRepliesChange ] ); + + /** + * Handles selecting a reply. + */ + const handleSelect = useCallback( + ( reply: string ) => { + onSelectReply( reply, commentId ); + }, + [ commentId, onSelectReply ] + ); + + /** + * Updates a reply in the list. + */ + const handleReplyChange = useCallback( + ( index: number, value: string ) => { + setReplies( ( prev ) => { + const updated = prev.map( ( r, i ) => + i === index ? value : r + ); + // Update cache with edited reply. + onRepliesChange?.( { replies: updated, tone } ); + return updated; + } ); + }, + [ tone, onRepliesChange ] + ); + + /** + * Generate replies on mount only if no cached replies. + */ + useEffect( () => { + if ( ! initialReplies?.replies?.length ) { + generateReplies(); + } + }, [] ); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + +
+ + + setTone( value as Tone ) } + __nextHasNoMarginBottom + /> + + + + + +
+ +
+ { isLoading && ( +
+ +

{ __( 'Generating reply suggestions…', 'ai' ) }

+
+ ) } + + { error && ( +
+

{ error }

+ +
+ ) } + + { ! isLoading && ! error && replies.length > 0 && ( + + { replies.map( ( reply, index ) => ( + + handleReplyChange( index, value ) + } + onSelect={ handleSelect } + /> + ) ) } + + ) } + + { ! isLoading && + ! error && + replies.length === 0 && + hasGenerated && ( +

+ { __( + 'No reply suggestions were generated. Please try again.', + 'ai' + ) } +

+ ) } +
+
+ ); +} diff --git a/src/experiments/comment-moderation/components/ReplyModalController.tsx b/src/experiments/comment-moderation/components/ReplyModalController.tsx new file mode 100644 index 00000000..e7bfb628 --- /dev/null +++ b/src/experiments/comment-moderation/components/ReplyModalController.tsx @@ -0,0 +1,238 @@ +/** + * Reply Modal Controller component. + * + * Manages the AI Reply modal for generating reply suggestions. + */ + +/** + * External dependencies + */ +import React from 'react'; + +/** + * WordPress dependencies + */ +import { useEffect, useState, useCallback, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { ReplyModal } from './ReplyModal'; + +type ModalState = { + isOpen: boolean; + commentId: number | null; +}; + +type CachedReplies = { + replies: string[]; + tone: 'professional' | 'friendly' | 'casual'; +}; + +/** + * ReplyModalController component. + * + * Listens for clicks on AI Reply links and opens the modal. + */ +export function ReplyModalController(): React.ReactElement { + const [ modalState, setModalState ] = useState< ModalState >( { + isOpen: false, + commentId: null, + } ); + + // Cache replies per comment ID so reopening shows previous results. + const repliesCache = useRef< Map< number, CachedReplies > >( new Map() ); + const pendingPopulateTimeout = useRef< number | null >( null ); + + /** + * Opens the modal for a specific comment. + */ + const openModal = useCallback( ( commentId: number ) => { + setModalState( { + isOpen: true, + commentId, + } ); + }, [] ); + + /** + * Closes the modal without clearing cache. + */ + const closeModal = useCallback( () => { + setModalState( ( prev ) => ( { + ...prev, + isOpen: false, + } ) ); + }, [] ); + + /** + * Gets cached replies for a comment. + */ + const getCachedReplies = useCallback( + ( commentId: number ): CachedReplies | undefined => { + return repliesCache.current.get( commentId ); + }, + [] + ); + + /** + * Caches replies for a comment. + */ + const setCachedReplies = useCallback( + ( commentId: number, data: CachedReplies ) => { + repliesCache.current.set( commentId, data ); + }, + [] + ); + + /** + * Populates the reply textarea with the selected reply. + */ + const populateReplyTextarea = useCallback( ( reply: string ) => { + const replyTextarea = document.querySelector< HTMLTextAreaElement >( + '#replycontainer #replycontent' + ); + + if ( replyTextarea ) { + replyTextarea.value = reply; + replyTextarea.focus(); + + // Trigger input event for any listeners. + replyTextarea.dispatchEvent( + new Event( 'input', { bubbles: true } ) + ); + } + }, [] ); + + /** + * Checks if the inline reply form is currently open for a specific comment. + */ + const isReplyFormOpenForComment = useCallback( + ( commentId: number ): boolean => { + const replyRow = + document.querySelector< HTMLElement >( '#replyrow' ); + const commentIdInput = document.querySelector< HTMLInputElement >( + '#replyrow #comment_ID' + ); + + if ( ! replyRow || ! commentIdInput ) { + return false; + } + + // Check if the reply row is visible and for the correct comment. + const isVisible = + replyRow.style.display !== 'none' && + replyRow.offsetParent !== null; + const isForComment = + parseInt( commentIdInput.value, 10 ) === commentId; + + return isVisible && isForComment; + }, + [] + ); + + /** + * Handles selecting a reply suggestion. + */ + const handleSelectReply = useCallback( + ( reply: string, commentId: number ) => { + // Check if the reply form is already open for this comment. + if ( isReplyFormOpenForComment( commentId ) ) { + // Just populate the existing textarea. + populateReplyTextarea( reply ); + closeModal(); + return; + } + + // Find the reply button for this comment and trigger WordPress's inline reply. + const replyButton = document.querySelector< HTMLButtonElement >( + `#comment-${ commentId } .reply button` + ); + + if ( replyButton ) { + // Click the reply button to open the inline reply form. + replyButton.click(); + + // Wait for the form to appear, then populate it. + if ( pendingPopulateTimeout.current !== null ) { + window.clearTimeout( pendingPopulateTimeout.current ); + } + pendingPopulateTimeout.current = window.setTimeout( () => { + populateReplyTextarea( reply ); + pendingPopulateTimeout.current = null; + }, 100 ); + } + + closeModal(); + }, + [ + closeModal, + isReplyFormOpenForComment, + populateReplyTextarea, + pendingPopulateTimeout, + ] + ); + + /** + * Sets up click handlers for AI Reply links. + */ + useEffect( () => { + const handleClick = ( event: MouseEvent ) => { + const target = event.target as HTMLElement; + + if ( ! target.classList.contains( 'ai-suggest-reply' ) ) { + return; + } + + event.preventDefault(); + + const commentId = parseInt( target.dataset.commentId || '0', 10 ); + + if ( commentId ) { + openModal( commentId ); + } + }; + + // Use event delegation on the comments table. + const commentsTable = document.querySelector( '#the-comment-list' ); + + if ( commentsTable ) { + commentsTable.addEventListener( + 'click', + handleClick as EventListener + ); + + return () => { + commentsTable.removeEventListener( + 'click', + handleClick as EventListener + ); + }; + } + + return undefined; + }, [ openModal ] ); + + useEffect( () => { + return () => { + if ( pendingPopulateTimeout.current !== null ) { + window.clearTimeout( pendingPopulateTimeout.current ); + } + }; + }, [] ); + + return ( + <> + { modalState.isOpen && modalState.commentId && ( + + setCachedReplies( modalState.commentId!, data ) + } + /> + ) } + + ); +} diff --git a/src/experiments/comment-moderation/index.tsx b/src/experiments/comment-moderation/index.tsx new file mode 100644 index 00000000..dfd643fb --- /dev/null +++ b/src/experiments/comment-moderation/index.tsx @@ -0,0 +1,54 @@ +/** + * Comment Moderation experiment entry point. + * + * Initializes lazy analysis for pending comments and handles + * the AI Reply modal functionality. + */ + +/** + * WordPress dependencies + */ +import domReady from '@wordpress/dom-ready'; +import { createRoot } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { LazyAnalysisController } from './components/LazyAnalysisController'; +import { ReplyModalController } from './components/ReplyModalController'; + +declare global { + interface Window { + aiCommentModerationData?: { + enabled: boolean; + nonce: string; + }; + } +} + +/** + * Initialize the comment moderation experiment. + */ +function init(): void { + const data = window.aiCommentModerationData; + + if ( ! data?.enabled ) { + return; + } + + // Create a container for the React components. + const container = document.createElement( 'div' ); + container.id = 'ai-comment-moderation-root'; + document.body.appendChild( container ); + + // Mount the React components. + const root = createRoot( container ); + root.render( + <> + + + + ); +} + +domReady( init ); diff --git a/src/utils/run-ability.ts b/src/utils/run-ability.ts new file mode 100644 index 00000000..d5727380 --- /dev/null +++ b/src/utils/run-ability.ts @@ -0,0 +1,123 @@ +/** + * Safe ability execution helper. + * + * Uses the Abilities API client when it's available and falls back to REST calls + * when the client script hasn't been enqueued yet. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +type AbilityInput = + | Record< string, unknown > + | Array< unknown > + | string + | number + | boolean + | null + | undefined; + +type Method = 'GET' | 'POST' | 'DELETE'; + +type RunAbilityOptions = { + method?: Method; +}; + +let hasShownFallbackNotice = false; + +const getAbilityClient = () => + ( window as Record< string, any > )?.wp?.abilities ?? null; + +const logFallbackWarning = () => { + if ( hasShownFallbackNotice ) { + return; + } + + // eslint-disable-next-line no-console + console.warn( + '[AI Experiments] wp.abilities.executeAbility is unavailable. Falling back to REST.' + ); + hasShownFallbackNotice = true; +}; + +const isAbilityNotFoundError = ( error: unknown ): boolean => { + if ( ! error || typeof error !== 'object' ) { + return false; + } + + const message = + 'message' in error && typeof ( error as any ).message === 'string' + ? ( error as any ).message + : ''; + const code = + 'code' in error && typeof ( error as any ).code === 'string' + ? ( error as any ).code + : ''; + + return ( + code === 'ability_not_found' || message.includes( 'Ability not found' ) + ); +}; + +const buildFetchOptions = ( + ability: string, + input: AbilityInput, + method: Method +) => { + const normalizedInput = input ?? null; + + if ( method === 'GET' || method === 'DELETE' ) { + return { + path: + normalizedInput === null + ? `/wp-abilities/v1/abilities/${ ability }/run` + : addQueryArgs( + `/wp-abilities/v1/abilities/${ ability }/run`, + { + input: normalizedInput, + } + ), + method, + }; + } + + return { + path: `/wp-abilities/v1/abilities/${ ability }/run`, + method: 'POST' as const, + data: { + input: normalizedInput, + }, + }; +}; + +export async function runAbility< T = unknown >( + ability: string, + input?: AbilityInput, + options?: RunAbilityOptions +): Promise< T > { + const client = getAbilityClient(); + + if ( typeof client?.executeAbility === 'function' ) { + try { + return await client.executeAbility( ability, input ?? null ); + } catch ( error ) { + if ( ! isAbilityNotFoundError( error ) ) { + throw error; + } + logFallbackWarning(); + } + } else { + logFallbackWarning(); + } + + const method: Method = options?.method ?? 'POST'; + + const response = await apiFetch( + buildFetchOptions( ability, input, method ) + ); + + return response as T; +} diff --git a/webpack.config.js b/webpack.config.js index 54f5e637..a5b6d17e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -39,6 +39,11 @@ module.exports = { 'src/experiments/title-generation', 'index.tsx' ), + 'experiments/comment-moderation': path.resolve( + process.cwd(), + 'src/experiments/comment-moderation', + 'index.tsx' + ), }, plugins: [