diff --git a/includes/Abilities/Internal_Links/Internal_Links.php b/includes/Abilities/Internal_Links/Internal_Links.php new file mode 100644 index 000000000..3f22da7df --- /dev/null +++ b/includes/Abilities/Internal_Links/Internal_Links.php @@ -0,0 +1,493 @@ + The input schema of the ability. + */ + protected function input_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'post_content' => array( + 'type' => 'string', + 'sanitize_callback' => 'wp_kses_post', + 'description' => esc_html__( 'The HTML content of the post being edited.', 'ai' ), + ), + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'ID of the post being edited.', 'ai' ), + ), + 'max_suggestions' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Maximum number of link suggestions to return (1–10).', 'ai' ), + 'default' => self::DEFAULT_MAX_SUGGESTIONS, + ), + 'excluded_anchors' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'description' => esc_html__( 'Anchor texts already hyperlinked in the post that should not be suggested again.', 'ai' ), + 'default' => array(), + ), + ), + 'required' => array( 'post_content', 'post_id' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @return array The output schema of the ability. + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'description' => esc_html__( 'Internal link suggestions for the post.', 'ai' ), + 'properties' => array( + 'suggestions' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'anchor_text' => array( + 'type' => 'string', + 'description' => esc_html__( 'Exact phrase from the post content to use as anchor text.', 'ai' ), + ), + 'url' => array( + 'type' => 'string', + 'description' => esc_html__( 'Permalink of the target post or page.', 'ai' ), + ), + 'title' => array( + 'type' => 'string', + 'description' => esc_html__( 'Title of the target post or page.', 'ai' ), + ), + 'context' => array( + 'type' => 'string', + 'description' => esc_html__( 'The sentence or clause from the post that contains the anchor text.', 'ai' ), + ), + ), + ), + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @param mixed $input The input arguments to the ability. + * @return array{suggestions: list}|\WP_Error + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'post_content' => '', + 'post_id' => 0, + 'max_suggestions' => self::DEFAULT_MAX_SUGGESTIONS, + 'excluded_anchors' => array(), + ) + ); + + $post_content = wp_kses_post( (string) $args['post_content'] ); + $post_id = absint( $args['post_id'] ); + $max_suggestions = min( absint( $args['max_suggestions'] ), self::MAX_SUGGESTIONS_CAP ); + $excluded_anchors = is_array( $args['excluded_anchors'] ) + ? array_values( array_filter( array_map( 'sanitize_text_field', $args['excluded_anchors'] ) ) ) + : array(); + + if ( empty( $post_content ) ) { + return new WP_Error( + 'post_content_required', + esc_html__( 'Post content is required to suggest internal links.', 'ai' ) + ); + } + + if ( $max_suggestions < 1 ) { + $max_suggestions = self::DEFAULT_MAX_SUGGESTIONS; + } + + // Convert HTML to plain text for anchor text matching. + $plain_text = normalize_content( wp_strip_all_tags( $post_content ) ); + + if ( empty( trim( $plain_text ) ) ) { + return array( 'suggestions' => array() ); + } + + // Build the list of linkable posts/pages from this site. + $site_index = $this->build_site_index( $post_id ); + + if ( empty( $site_index ) ) { + return array( 'suggestions' => array() ); + } + + $prompt = $this->create_prompt( $plain_text, $site_index, $max_suggestions, $excluded_anchors ); + $prompt_builder = $this->get_prompt_builder( $prompt ); + + if ( is_wp_error( $prompt_builder ) ) { + return $prompt_builder; + } + + $raw = $prompt_builder->generate_text(); + + if ( is_wp_error( $raw ) ) { + return $raw; + } + + if ( empty( $raw ) ) { + return array( 'suggestions' => array() ); + } + + $suggestions = $this->parse_and_validate_response( (string) $raw, $plain_text, $site_index, $max_suggestions ); + + return array( 'suggestions' => $suggestions ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @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 ) { + $post_id = isset( $input['post_id'] ) ? absint( $input['post_id'] ) : 0; + + if ( ! $post_id ) { + if ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to use AI internal link suggestions.', 'ai' ) + ); + } + + return true; + } + + $post = get_post( $post_id ); + + if ( ! $post ) { + return new WP_Error( + 'post_not_found', + /* translators: %d: Post ID. */ + sprintf( esc_html__( 'Post with ID %d not found.', 'ai' ), $post_id ) + ); + } + + if ( ! current_user_can( 'edit_post', $post_id ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to run AI internal link suggestions on this post.', 'ai' ) + ); + } + + $post_type = get_post_type( $post_id ); + $post_type_obj = $post_type ? get_post_type_object( $post_type ) : null; + + return $post_type_obj && ! empty( $post_type_obj->show_in_rest ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + ); + } + + /** + * Returns the JSON schema used for structured output generation. + * + * @since x.x.x + * + * @return array JSON schema for an array of suggestions. + */ + private function suggestions_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'suggestions' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'anchor_text' => array( 'type' => 'string' ), + 'url' => array( 'type' => 'string' ), + 'title' => array( 'type' => 'string' ), + 'context' => array( 'type' => 'string' ), + ), + 'required' => array( 'anchor_text', 'url', 'title', 'context' ), + 'additionalProperties' => false, + ), + ), + ), + 'required' => array( 'suggestions' ), + 'additionalProperties' => false, + ); + } + + /** + * Builds a compact site index of published posts and pages for the AI prompt. + * + * Excludes the current post being edited. + * + * @since x.x.x + * + * @param int $exclude_post_id The ID of the post currently being edited. + * @return list List of linkable posts. + */ + private function build_site_index( int $exclude_post_id ): array { + $query = new WP_Query( + array( + 'post_type' => array( 'post', 'page' ), + 'post_status' => 'publish', + 'posts_per_page' => self::SITE_INDEX_LIMIT, + 'post__not_in' => $exclude_post_id ? array( $exclude_post_id ) : array(), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_post__not_in, WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in + 'no_found_rows' => true, + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + 'fields' => 'ids', + ) + ); + + $index = array(); + + foreach ( $query->posts as $id ) { + $title = get_the_title( $id ); + $url = get_permalink( $id ); + + if ( ! $title || ! $url ) { + continue; + } + + $index[] = array( + 'url' => esc_url_raw( $url ), + 'title' => sanitize_text_field( $title ), + ); + } + + return $index; + } + + /** + * Builds the prompt string to send to the AI. + * + * @since x.x.x + * + * @param string $plain_text Plain-text post content. + * @param list $site_index List of linkable posts. + * @param int $max_suggestions Maximum number of suggestions. + * @param list $excluded_anchors Anchor texts already hyperlinked in the post. + * @return string The assembled prompt. + */ + private function create_prompt( string $plain_text, array $site_index, int $max_suggestions, array $excluded_anchors = array() ): string { + $index_lines = array(); + foreach ( $site_index as $entry ) { + $index_lines[] = sprintf( '- %s <%s>', $entry['title'], $entry['url'] ); + } + + $parts = array(); + $parts[] = '' . $plain_text . ''; + $parts[] = '' . implode( "\n", $index_lines ) . ''; + $parts[] = '' . $max_suggestions . ''; + + if ( ! empty( $excluded_anchors ) ) { + $anchor_lines = array(); + foreach ( $excluded_anchors as $anchor ) { + $anchor_lines[] = '- ' . $anchor; + } + $parts[] = '' . implode( "\n", $anchor_lines ) . ''; + } + + return implode( "\n\n", $parts ); + } + + /** + * Gets a configured prompt builder for the internal links suggestion. + * + * @since x.x.x + * + * @param string $prompt The assembled prompt. + * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or WP_Error on failure. + */ + private function get_prompt_builder( string $prompt ) { + $prompt_builder = wp_ai_client_prompt( $prompt ) + ->using_system_instruction( $this->get_system_instruction() ) + ->as_json_response( $this->suggestions_schema() ); + + $config = \WordPress\AI\get_feature_developer_model_config( Internal_Links_Experiment::get_id() ); + if ( ! empty( $config['provider'] ) && ! empty( $config['model'] ) ) { + $prompt_builder->using_model_preference( array( $config['provider'], $config['model'] ) ); + } else { + $prompt_builder->using_model_preference( ...\WordPress\AI\get_preferred_models_for_text_generation() ); + } + + return $this->ensure_text_generation_supported( + $prompt_builder, + esc_html__( 'Internal link suggestions could not be generated. Please ensure you have a connected provider that supports text generation.', 'ai' ) + ); + } + + /** + * Parses the raw AI JSON response and validates each suggestion. + * + * Validation rules: + * - anchor_text must exist verbatim in the plain-text content. + * - url must be present in the site index. + * - No duplicate anchor texts or URLs. + * - Capped at max_suggestions. + * + * @since x.x.x + * + * @param string $raw Raw JSON string from the AI. + * @param string $plain_text Plain-text post content. + * @param list $site_index List of linkable posts. + * @param int $max_suggestions Maximum number of suggestions. + * @return list + */ + private function parse_and_validate_response( string $raw, string $plain_text, array $site_index, int $max_suggestions ): array { + $decoded = json_decode( $raw, true ); + + if ( ! is_array( $decoded ) || ! isset( $decoded['suggestions'] ) || ! is_array( $decoded['suggestions'] ) ) { + return array(); + } + + // Build a fast URL lookup set from the site index. + $valid_urls = array(); + foreach ( $site_index as $entry ) { + $valid_urls[ $entry['url'] ] = true; + } + + $suggestions = array(); + $seen_anchors = array(); + $seen_urls = array(); + + foreach ( $decoded['suggestions'] as $item ) { + if ( count( $suggestions ) >= $max_suggestions ) { + break; + } + + if ( + ! is_array( $item ) || + empty( $item['anchor_text'] ) || + empty( $item['url'] ) || + empty( $item['title'] ) || + ! is_string( $item['anchor_text'] ) || + ! is_string( $item['url'] ) || + ! is_string( $item['title'] ) + ) { + continue; + } + + $anchor_text = sanitize_text_field( $item['anchor_text'] ); + $url = esc_url_raw( $item['url'] ); + $title = sanitize_text_field( $item['title'] ); + $context = sanitize_text_field( $item['context'] ?? '' ); + + // Anchor text must exist verbatim in the post content. + if ( ! str_contains( $plain_text, $anchor_text ) ) { + continue; + } + + // URL must come from the site index. + if ( ! isset( $valid_urls[ $url ] ) ) { + continue; + } + + // No duplicate anchor texts. + if ( isset( $seen_anchors[ $anchor_text ] ) ) { + continue; + } + + // No duplicate URLs. + if ( isset( $seen_urls[ $url ] ) ) { + continue; + } + + $seen_anchors[ $anchor_text ] = true; + $seen_urls[ $url ] = true; + + $suggestions[] = array( + 'anchor_text' => $anchor_text, + 'url' => $url, + 'title' => $title, + 'context' => $context, + ); + } + + return $suggestions; + } +} diff --git a/includes/Abilities/Internal_Links/system-instruction.php b/includes/Abilities/Internal_Links/system-instruction.php new file mode 100644 index 000000000..d97d0d027 --- /dev/null +++ b/includes/Abilities/Internal_Links/system-instruction.php @@ -0,0 +1,29 @@ + tags. Do NOT invent, rephrase, or summarise. Copy the phrase character-for-character. +2. **Match to the site index.** Each suggestion must reference a URL from the list. Do NOT invent URLs. +3. **Relevance first.** Only suggest a link when the target page is genuinely relevant to the anchor phrase in context. Avoid superficial keyword matches. +4. **No duplicates.** Do not suggest the same anchor text or the same URL more than once. +5. **Respect the cap.** Return at most the number of suggestions specified in . +6. **Context sentence.** For each suggestion, copy the sentence or clause from the post that contains the anchor text into the `context` field. This helps the editor understand placement. +7. **Quality over quantity.** If fewer than high-quality links exist, return fewer. An empty array is valid if no good matches exist. +8. **Skip already-linked text.** If an `` list is provided, do NOT suggest any anchor text that appears in that list. Those phrases are already hyperlinked in the post. + + +INSTRUCTION; diff --git a/includes/Experiments/Experiments.php b/includes/Experiments/Experiments.php index aedf63d87..fa4bd98ce 100644 --- a/includes/Experiments/Experiments.php +++ b/includes/Experiments/Experiments.php @@ -30,20 +30,21 @@ final class Experiments { \WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer::class, \WordPress\AI\Experiments\Custom_Abilities\Custom_Abilities::class, \WordPress\AI\Experiments\AI_Request_Logging\AI_Request_Logging::class, - \WordPress\AI\Experiments\Connector_Approval\Connector_Approval::class, - \WordPress\AI\Experiments\Key_Encryption\Key_Encryption::class, - \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, - \WordPress\AI\Experiments\Suggest_Reply\Suggest_Reply::class, \WordPress\AI\Experiments\Alt_Text_Generation\Alt_Text_Generation::class, + \WordPress\AI\Experiments\Comment_Moderation\Comment_Moderation::class, + \WordPress\AI\Experiments\Connector_Approval\Connector_Approval::class, \WordPress\AI\Experiments\Content_Classification\Content_Classification::class, \WordPress\AI\Experiments\Content_Resizing\Content_Resizing::class, - \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Content_Translation\Content_Translation::class, \WordPress\AI\Experiments\Editorial_Notes\Editorial_Notes::class, \WordPress\AI\Experiments\Editorial_Updates\Editorial_Updates::class, \WordPress\AI\Experiments\Excerpt_Generation\Excerpt_Generation::class, + \WordPress\AI\Experiments\Internal_Links\Internal_Links::class, + \WordPress\AI\Experiments\Key_Encryption\Key_Encryption::class, \WordPress\AI\Experiments\Meta_Description\Meta_Description::class, \WordPress\AI\Experiments\Slug_Generation\Slug_Generation::class, + \WordPress\AI\Experiments\Suggest_Reply\Suggest_Reply::class, + \WordPress\AI\Experiments\Summarization\Summarization::class, \WordPress\AI\Experiments\Title_Generation\Title_Generation::class, \WordPress\AI\Experiments\Type_Ahead\Type_Ahead::class, ); diff --git a/includes/Experiments/Internal_Links/Internal_Links.php b/includes/Experiments/Internal_Links/Internal_Links.php new file mode 100644 index 000000000..fcde05676 --- /dev/null +++ b/includes/Experiments/Internal_Links/Internal_Links.php @@ -0,0 +1,113 @@ + __( 'Internal Link Suggestions', 'ai' ), + 'description' => __( 'Suggests relevant internal links within post content, using existing text as anchor text. All suggestions require editor review before being applied. Requires an AI connector that includes support for text generation models.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'enqueue_block_editor_assets', array( $this, 'enqueue_assets' ), 5 ); + } + + /** + * Registers the internal links ability. + * + * @since x.x.x + */ + public function register_abilities(): void { + wp_register_ability( + 'ai/' . $this->get_id(), + array( + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'ability_class' => Internal_Links_Ability::class, + ) + ); + } + + /** + * Enqueues and localises the block editor script. + * + * @since x.x.x + */ + public function enqueue_assets(): void { + Asset_Loader::enqueue_script( 'internal_links', 'experiments/internal-links', array( 'include_core_abilities' => true ) ); + Asset_Loader::enqueue_style( 'internal_links', 'experiments/internal-links' ); + Asset_Loader::localize_script( + 'internal_links', + 'InternalLinksData', + array( + 'enabled' => $this->is_enabled(), + 'minContentLength' => get_min_content_length( 'internal-links', 75 ), + 'maxSuggestions' => $this->get_max_suggestions(), + ) + ); + } + + /** + * Returns the configured maximum number of link suggestions. + * + * Defaults to 5 and can be overridden via the `wpai_internal_links_max_suggestions` filter. + * + * @since x.x.x + * + * @return int Maximum number of suggestions (clamped to 1–10). + */ + private function get_max_suggestions(): int { + /** + * Filters the maximum number of internal link suggestions returned per request. + * + * @since x.x.x + * + * @param int $max Maximum suggestions (default 5, clamped to 1–10). + */ + $max = (int) apply_filters( 'wpai_internal_links_max_suggestions', 5 ); + + return max( 1, min( 10, $max ) ); + } +} diff --git a/src/experiments/internal-links/components/InternalLinksPlugin.tsx b/src/experiments/internal-links/components/InternalLinksPlugin.tsx new file mode 100644 index 000000000..21c5125e3 --- /dev/null +++ b/src/experiments/internal-links/components/InternalLinksPlugin.tsx @@ -0,0 +1,106 @@ +/** + * WordPress dependencies + */ +import { Button, Flex, FlexItem, Spinner } from '@wordpress/components'; +import { PluginPostStatusInfo } from '@wordpress/editor'; +import { useInstanceId } from '@wordpress/compose'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { link } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { useInternalLinks } from '../hooks/useInternalLinks'; +import SuggestionList from './SuggestionList'; + +export default function InternalLinksPlugin() { + const { + isLoading, + suggestions, + isContentTooShort, + minContentLength, + fetchSuggestions, + acceptSuggestion, + dismissSuggestion, + } = useInternalLinks(); + + const descriptionId = useInstanceId( + InternalLinksPlugin, + 'internal-links-plugin-description' + ); + + if ( ! ( window as any ).aiInternalLinksData?.enabled ) { + return null; + } + + const buttonLabel = isLoading + ? __( 'Suggesting links…', 'ai' ) + : __( 'Suggest Internal Links', 'ai' ); + + const buttonDescription = isContentTooShort + ? sprintf( + /* translators: %d: minimum number of characters required. */ + __( + 'Internal Link Suggestions will be available when the content has at least %d characters.', + 'ai' + ), + minContentLength + ) + : __( + 'Analyses this content and suggests relevant internal links using existing text as anchor text.', + 'ai' + ); + + return ( + + + + + + + + + { buttonDescription } + + + + { suggestions.length > 0 && ( + +

+ { sprintf( + /* translators: %d: number of suggestions found. */ + _n( + '%d suggestion found.', + '%d suggestions found.', + suggestions.length, + 'ai' + ), + suggestions.length + ) } +

+ +
+ ) } +
+
+ ); +} diff --git a/src/experiments/internal-links/components/SuggestionList.tsx b/src/experiments/internal-links/components/SuggestionList.tsx new file mode 100644 index 000000000..2f127c475 --- /dev/null +++ b/src/experiments/internal-links/components/SuggestionList.tsx @@ -0,0 +1,75 @@ +/** + * WordPress dependencies + */ +import { Button, ExternalLink } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { check, trash } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import type { LinkSuggestion } from '../hooks/useInternalLinks'; + +interface Props { + suggestions: LinkSuggestion[]; + onAccept: ( suggestion: LinkSuggestion ) => void; + onDismiss: ( suggestion: LinkSuggestion ) => void; +} + +export default function SuggestionList( { + suggestions, + onAccept, + onDismiss, +}: Props ) { + if ( suggestions.length === 0 ) { + return null; + } + + return ( +
    + { suggestions.map( ( suggestion ) => ( +
  • +

    + { `"${ suggestion.anchor_text }"` } +

    +

    + { __( 'Links to:', 'ai' ) }{ ' ' } + + { suggestion.title } + +

    + { suggestion.context && ( +

    + { `"…${ suggestion.context }…"` } +

    + ) } +
    + + +
    +
  • + ) ) } +
+ ); +} diff --git a/src/experiments/internal-links/hooks/useInternalLinks.ts b/src/experiments/internal-links/hooks/useInternalLinks.ts new file mode 100644 index 000000000..c41fb6ee1 --- /dev/null +++ b/src/experiments/internal-links/hooks/useInternalLinks.ts @@ -0,0 +1,284 @@ +/** + * WordPress dependencies + */ +import { dispatch, select, useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useState } from '@wordpress/element'; +import { store as noticesStore } from '@wordpress/notices'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { runAbility } from '../../../utils/run-ability'; +import { ensureProvider } from '../../../utils/provider-status'; +import { hasMinimumContent } from '../../../utils/character-count'; + +const NOTICE_ID = 'ai_internal_links_error'; +const MINIMUM_CONTENT_COUNT_DEFAULT = 75; + +export interface LinkSuggestion { + anchor_text: string; + url: string; + title: string; + context: string; +} + +interface SuggestionResponse { + suggestions: LinkSuggestion[]; +} + +interface BlockAttributes { + content?: unknown; + value?: unknown; + [ key: string ]: unknown; +} + +interface Block { + clientId: string; + name: string; + attributes: BlockAttributes; + innerBlocks: Block[]; +} + +/** + * Converts a RichText attribute value (string or object) to a plain string. + * + * @param value Attribute value. + * @return Plain text string. + */ +function toPlainString( value: unknown ): string { + if ( typeof value === 'string' ) { + return value; + } + if ( + value && + typeof value === 'object' && + 'text' in value && + typeof ( value as { text?: unknown } ).text === 'string' + ) { + return ( value as { text: string } ).text; + } + return ''; +} + +/** + * Strips HTML tags from a string. + * + * @param html HTML string. + * @return Plain text. + */ +function stripTags( html: string ): string { + const div = document.createElement( 'div' ); + div.innerHTML = html; + return div.textContent ?? div.innerText ?? ''; +} + +/** + * Returns the set of anchor texts already hyperlinked in the post HTML. + * + * Parses every element from the raw post content so that suggestions + * whose anchor_text is already linked can be excluded from the results. + * + * @param html Raw HTML post content. + * @return Set of already-linked text strings (lowercased for comparison). + */ +function getLinkedAnchorTexts( html: string ): Set< string > { + const div = document.createElement( 'div' ); + div.innerHTML = html; + const linked = new Set< string >(); + div.querySelectorAll( 'a' ).forEach( ( anchor ) => { + const text = anchor.textContent?.trim(); + if ( text ) { + linked.add( text.toLowerCase() ); + } + } ); + return linked; +} + +/** + * Recursively flattens a block tree. + * + * @param blocks Top-level blocks. + * @return Flat array of all blocks. + */ +function flattenAll( blocks: Block[] ): Block[] { + return blocks.reduce< Block[] >( ( acc, block ) => { + acc.push( block ); + if ( block.innerBlocks?.length ) { + acc.push( ...flattenAll( block.innerBlocks ) ); + } + return acc; + }, [] ); +} + +/** + * Applies an internal link suggestion to the block editor. + * + * Finds the first block whose plain-text content contains the anchor text + * and wraps that first occurrence in an HTML tag. + * + * @param suggestion The accepted link suggestion. + * @param blocks All blocks in the editor. + */ +function applyLinkToBlock( suggestion: LinkSuggestion, blocks: Block[] ): void { + const flat = flattenAll( blocks ); + const { anchor_text: anchorText, url } = suggestion; + + for ( const block of flat ) { + const rawContent = toPlainString( + block.attributes.content ?? block.attributes.value ?? '' + ); + const plainContent = stripTags( rawContent ); + + if ( ! plainContent.includes( anchorText ) ) { + continue; + } + + const escapedAnchor = anchorText.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&' + ); + const regex = new RegExp( `(${ escapedAnchor })`, '' ); + const updatedHtml = rawContent.replace( + regex, + `${ anchorText }` + ); + + const attributeKey = + 'content' in block.attributes ? 'content' : 'value'; + + dispatch( blockEditorStore ).updateBlockAttributes( block.clientId, { + [ attributeKey ]: updatedHtml, + } ); + + return; + } +} + +/** + * Hook for Internal Link Suggestions functionality. + * + * @return State and handlers for the internal links feature. + */ +export function useInternalLinks(): { + isLoading: boolean; + suggestions: LinkSuggestion[]; + isContentTooShort: boolean; + minContentLength: number; + fetchSuggestions: () => Promise< void >; + acceptSuggestion: ( suggestion: LinkSuggestion ) => void; + dismissSuggestion: ( suggestion: LinkSuggestion ) => void; +} { + const [ isLoading, setIsLoading ] = useState< boolean >( false ); + const [ suggestions, setSuggestions ] = useState< LinkSuggestion[] >( [] ); + + const minContentLength: number = + ( window as any ).aiInternalLinksData?.minContentLength ?? + MINIMUM_CONTENT_COUNT_DEFAULT; + + const maxSuggestions: number = parseInt( + ( window as any ).aiInternalLinksData?.maxSuggestions ?? 5, + 10 + ); + + const { content, postId } = useSelect( ( selectStore ) => { + const editor = selectStore( editorStore ); + return { + content: editor.getEditedPostContent() as string, + postId: editor.getCurrentPostId() as number, + }; + }, [] ); + + const isContentTooShort = ! hasMinimumContent( content, minContentLength ); + + const fetchSuggestions = async () => { + if ( ! ensureProvider( NOTICE_ID ) ) { + return; + } + + if ( isContentTooShort ) { + return; + } + + setIsLoading( true ); + setSuggestions( [] ); + + dispatch( noticesStore ).removeNotice( NOTICE_ID ); + + const alreadyLinked = getLinkedAnchorTexts( content ); + const excludedAnchors = [ ...alreadyLinked ]; + + try { + const result = await runAbility< SuggestionResponse >( + 'ai/internal-links', + { + post_content: content, + post_id: postId, + max_suggestions: maxSuggestions, + excluded_anchors: excludedAnchors, + } + ); + + const fetchedSuggestions = result?.suggestions ?? []; + setSuggestions( fetchedSuggestions ); + + if ( fetchedSuggestions.length === 0 ) { + dispatch( noticesStore ).createNotice( + 'info', + __( + 'No internal link suggestions found for this content.', + 'ai' + ), + { type: 'snackbar' } + ); + } + } catch ( error: any ) { + dispatch( noticesStore ).createErrorNotice( + error?.message ?? String( error ), + { + id: NOTICE_ID, + isDismissible: true, + } + ); + } finally { + setIsLoading( false ); + } + }; + + const acceptSuggestion = ( suggestion: LinkSuggestion ) => { + const blocks = select( blockEditorStore ).getBlocks() as Block[]; + + applyLinkToBlock( suggestion, blocks ); + + // Remove the accepted suggestion from the list. + setSuggestions( ( prev ) => + prev.filter( ( s ) => s.anchor_text !== suggestion.anchor_text ) + ); + + dispatch( noticesStore ).createSuccessNotice( + __( + 'Internal link applied. Save the post to keep the change.', + 'ai' + ), + { type: 'snackbar' } + ); + }; + + const dismissSuggestion = ( suggestion: LinkSuggestion ) => { + setSuggestions( ( prev ) => + prev.filter( ( s ) => s.anchor_text !== suggestion.anchor_text ) + ); + }; + + return { + isLoading, + suggestions, + isContentTooShort, + minContentLength, + fetchSuggestions, + acceptSuggestion, + dismissSuggestion, + }; +} diff --git a/src/experiments/internal-links/index.scss b/src/experiments/internal-links/index.scss new file mode 100644 index 000000000..d19dee906 --- /dev/null +++ b/src/experiments/internal-links/index.scss @@ -0,0 +1,47 @@ +.ai-internal-links__plugin-button { + justify-content: center !important; + width: 100%; +} + +.ai-internal-links__plugin-description { + color: #757575; +} + +.ai-internal-links__suggestions-header { + margin: 4px 0 8px; + font-weight: 600; +} + +.ai-internal-links__suggestions { + list-style: none; + margin: 0; + padding: 0; +} + +.ai-internal-links__suggestion { + border-bottom: 1px solid #ddd; + padding-bottom: 10px; + margin-bottom: 10px; +} + +.ai-internal-links__suggestion-anchor { + margin: 0 0 4px; +} + +.ai-internal-links__suggestion-target { + margin: 0 0 4px; + font-size: 12px; + color: #555; +} + +.ai-internal-links__suggestion-context { + margin: 0 0 8px; + font-size: 11px; + color: #757575; + font-style: italic; +} + +.ai-internal-links__suggestion-actions { + display: flex; + gap: 6px; +} diff --git a/src/experiments/internal-links/index.tsx b/src/experiments/internal-links/index.tsx new file mode 100644 index 000000000..25b052506 --- /dev/null +++ b/src/experiments/internal-links/index.tsx @@ -0,0 +1,26 @@ +/** + * WordPress dependencies + */ +import { registerPlugin } from '@wordpress/plugins'; + +/** + * Internal dependencies + */ +import InternalLinksPlugin from './components/InternalLinksPlugin'; +import './index.scss'; + +declare global { + interface Window { + aiInternalLinksData?: { + enabled: boolean; + minContentLength: number; + maxSuggestions: number; + }; + } +} + +if ( ( window as any ).aiInternalLinksData?.enabled ) { + registerPlugin( 'ai-internal-links', { + render: () => , + } ); +} diff --git a/tests/Integration/Includes/Abilities/Internal_LinksTest.php b/tests/Integration/Includes/Abilities/Internal_LinksTest.php new file mode 100644 index 000000000..60631393e --- /dev/null +++ b/tests/Integration/Includes/Abilities/Internal_LinksTest.php @@ -0,0 +1,372 @@ + 'Internal Link Suggestions', + 'description' => 'Uses AI to suggest relevant internal links within post content.', + ); + } + + /** + * Registers the experiment. + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Internal_Links Ability test case. + * + * @since x.x.x + */ +class Internal_LinksTest extends WP_UnitTestCase { + /** + * Internal_Links ability instance. + * + * @var \WordPress\AI\Abilities\Internal_Links\Internal_Links + */ + private $ability; + + /** + * Test experiment instance. + * + * @var \WordPress\AI\Tests\Integration\Includes\Abilities\Test_Internal_Links_Experiment + */ + private $experiment; + + /** + * Set up test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $this->experiment = new Test_Internal_Links_Experiment(); + $this->ability = new Internal_Links( + 'ai/internal-links', + array( + 'label' => $this->experiment->get_label(), + 'description' => $this->experiment->get_description(), + ) + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Test that category() returns the correct category. + * + * @since x.x.x + */ + public function test_category_returns_correct_category() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'category' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability ); + + $this->assertSame( 'ai-experiments', $result, 'Category should be ai-experiments' ); + } + + /** + * Test that input_schema() returns the expected structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'input_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'post_content', $schema['properties'] ); + $this->assertArrayHasKey( 'post_id', $schema['properties'] ); + $this->assertArrayHasKey( 'max_suggestions', $schema['properties'] ); + $this->assertSame( 5, $schema['properties']['max_suggestions']['default'] ); + $this->assertContains( 'post_content', $schema['required'] ); + $this->assertContains( 'post_id', $schema['required'] ); + } + + /** + * Test that output_schema() returns the expected structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'output_schema' ); + $method->setAccessible( true ); + + $schema = $method->invoke( $this->ability ); + + $this->assertIsArray( $schema ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'suggestions', $schema['properties'] ); + + $item_props = $schema['properties']['suggestions']['items']['properties']; + $this->assertArrayHasKey( 'anchor_text', $item_props ); + $this->assertArrayHasKey( 'url', $item_props ); + $this->assertArrayHasKey( 'title', $item_props ); + $this->assertArrayHasKey( 'context', $item_props ); + } + + /** + * Test that execute_callback() returns error when post_content is missing. + * + * @since x.x.x + */ + public function test_execute_callback_without_post_content() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( + $this->ability, + array( + 'post_id' => 1, + 'post_content' => '', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'post_content_required', $result->get_error_code() ); + } + + /** + * Test that execute_callback() returns a WP_Error when no text-generation model is available. + * + * @since x.x.x + */ + public function test_execute_callback_returns_error_when_no_text_generation_model_available() { + remove_filter( 'wpai_has_ai_credentials', '__return_true' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + delete_option( 'wp_ai_client_provider_credentials' ); + + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_title' => 'Other Post', + ) + ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'execute_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( + $this->ability, + array( + 'post_id' => $post_id, + 'post_content' => 'Check out our Other Post for more details.', + ) + ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'unsupported_model', $result->get_error_code() ); + } + + /** + * Test that permission_callback() allows authorized users. + * + * @since x.x.x + */ + public function test_permission_callback_allows_authorized_user() { + $post_id = self::factory()->post->create( array( 'post_status' => 'publish' ) ); + $user_id = self::factory()->user->create( array( 'role' => 'administrator' ) ); + wp_set_current_user( $user_id ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertTrue( $result ); + } + + /** + * Test that permission_callback() denies unauthorized users. + * + * @since x.x.x + */ + public function test_permission_callback_denies_unauthorized_user() { + $post_id = self::factory()->post->create(); + $user_id = self::factory()->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'permission_callback' ); + $method->setAccessible( true ); + + $result = $method->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'insufficient_capabilities', $result->get_error_code() ); + } + + /** + * Test that meta() returns expected shape. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure() { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'meta' ); + $method->setAccessible( true ); + + $meta = $method->invoke( $this->ability ); + + $this->assertIsArray( $meta ); + $this->assertArrayHasKey( 'show_in_rest', $meta ); + $this->assertTrue( $meta['show_in_rest'] ); + } + + /** + * Test that get_system_instruction() returns expected content. + * + * @since x.x.x + */ + public function test_get_system_instruction_returns_expected_content() { + $system_instruction = $this->ability->get_system_instruction(); + + $this->assertIsString( $system_instruction ); + $this->assertNotEmpty( $system_instruction ); + $this->assertStringContainsString( 'internal-linking assistant', $system_instruction ); + } + + /** + * Test that build_site_index() builds index of published posts excluding current post. + * + * @since x.x.x + */ + public function test_build_site_index_excludes_current_post_and_drafts() { + self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_title' => 'Published Post', + ) + ); + self::factory()->post->create( + array( + 'post_status' => 'draft', + 'post_title' => 'Draft Post', + ) + ); + $current_post_id = self::factory()->post->create( + array( + 'post_status' => 'publish', + 'post_title' => 'Current Post', + ) + ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'build_site_index' ); + $method->setAccessible( true ); + + $index = $method->invoke( $this->ability, $current_post_id ); + $titles = array_column( $index, 'title' ); + + $this->assertContains( 'Published Post', $titles ); + $this->assertNotContains( 'Draft Post', $titles ); + $this->assertNotContains( 'Current Post', $titles ); + } + + /** + * Test parse_and_validate_response() validates anchor text, site index URLs, and removes invalid suggestions. + * + * @since x.x.x + */ + public function test_parse_and_validate_response() { + $plain_text = 'Learn more about WordPress REST API for content management.'; + $site_index = array( + array( + 'url' => 'https://example.com/rest-api/', + 'title' => 'REST API Guide', + ), + ); + + $raw_json = wp_json_encode( + array( + 'suggestions' => array( + // Valid suggestion. + array( + 'anchor_text' => 'REST API', + 'url' => 'https://example.com/rest-api/', + 'title' => 'REST API Guide', + 'context' => 'Learn more about WordPress REST API for content management.', + ), + // Invalid anchor text (not in plain text). + array( + 'anchor_text' => 'Gutenberg', + 'url' => 'https://example.com/rest-api/', + 'title' => 'REST API Guide', + 'context' => 'Invalid', + ), + // Invalid URL (not in site index). + array( + 'anchor_text' => 'content management', + 'url' => 'https://example.com/other/', + 'title' => 'Other', + 'context' => 'Invalid', + ), + ), + ) + ); + + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( 'parse_and_validate_response' ); + $method->setAccessible( true ); + + $suggestions = $method->invoke( $this->ability, $raw_json, $plain_text, $site_index, 5 ); + + $this->assertCount( 1, $suggestions ); + $this->assertSame( 'REST API', $suggestions[0]['anchor_text'] ); + $this->assertSame( 'https://example.com/rest-api/', $suggestions[0]['url'] ); + } +} diff --git a/tests/Integration/Includes/Experiments/Internal_Links/Internal_LinksTest.php b/tests/Integration/Includes/Experiments/Internal_Links/Internal_LinksTest.php new file mode 100644 index 000000000..2d5d0cfb0 --- /dev/null +++ b/tests/Integration/Includes/Experiments/Internal_Links/Internal_LinksTest.php @@ -0,0 +1,164 @@ + 'test-api-key' ) ); + add_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + + update_option( 'wpai_features_enabled', true ); + update_option( 'wpai_feature_internal-links_enabled', true ); + + $registry = new Registry(); + $loader = new Loader( $registry ); + $loader->init(); + + $experiment = $registry->get_feature( 'internal-links' ); + $this->assertInstanceOf( + Internal_Links::class, + $experiment, + 'Internal links experiment should be registered in the registry.' + ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + wp_dequeue_style( 'ai_internal_links' ); + wp_deregister_style( 'ai_internal_links' ); + wp_dequeue_script( 'ai_internal_links' ); + wp_deregister_script( 'ai_internal_links' ); + delete_option( 'wpai_features_enabled' ); + delete_option( 'wpai_feature_internal-links_enabled' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Tests that the experiment reports correct metadata. + * + * @since x.x.x + */ + public function test_experiment_registration(): void { + $experiment = new Internal_Links(); + + $this->assertSame( 'internal-links', $experiment->get_id() ); + $this->assertSame( 'Internal Link Suggestions', $experiment->get_label() ); + $this->assertSame( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Tests that the experiment can be disabled via the filter. + * + * @since x.x.x + */ + public function test_experiment_can_be_disabled_via_filter(): void { + add_filter( 'wpai_feature_internal-links_enabled', '__return_false' ); + + $experiment = new Internal_Links(); + $this->assertFalse( $experiment->is_enabled() ); + + remove_all_filters( 'wpai_feature_internal-links_enabled' ); + } + + /** + * Tests that register() hooks the expected actions. + * + * @since x.x.x + */ + public function test_register_hooks_expected_actions(): void { + $experiment = new Internal_Links(); + $experiment->register(); + + $this->assertNotFalse( + has_action( 'wp_abilities_api_init', array( $experiment, 'register_abilities' ) ), + 'register_abilities should be hooked to wp_abilities_api_init' + ); + $this->assertNotFalse( + has_action( 'enqueue_block_editor_assets', array( $experiment, 'enqueue_assets' ) ), + 'enqueue_assets should be hooked to enqueue_block_editor_assets' + ); + } + + /** + * Tests that enqueue_assets() enqueues the script and localizes data. + * + * @since x.x.x + */ + public function test_enqueue_assets_enqueues_script_and_localizes_data(): void { + $experiment = new Internal_Links(); + $experiment->enqueue_assets(); + + $this->assertTrue( wp_script_is( 'ai_internal_links', 'enqueued' ) ); + + $localized = (string) wp_scripts()->get_data( 'ai_internal_links', 'data' ); + $this->assertStringContainsString( 'enabled', $localized ); + $this->assertStringContainsString( 'minContentLength', $localized ); + $this->assertStringContainsString( 'maxSuggestions', $localized ); + } + + /** + * Tests that enqueue_assets() localizes the default max suggestions value. + * + * @since x.x.x + */ + public function test_enqueue_assets_localizes_default_max_suggestions(): void { + $experiment = new Internal_Links(); + $experiment->enqueue_assets(); + + $localized = (string) wp_scripts()->get_data( 'ai_internal_links', 'data' ); + $this->assertStringContainsString( '"maxSuggestions":"5"', $localized ); + } + + /** + * Tests that the wpai_internal_links_max_suggestions filter overrides the default. + * + * @since x.x.x + */ + public function test_enqueue_assets_respects_max_suggestions_filter(): void { + add_filter( + 'wpai_internal_links_max_suggestions', + static function () { + return 3; + } + ); + + $experiment = new Internal_Links(); + $experiment->enqueue_assets(); + + remove_all_filters( 'wpai_internal_links_max_suggestions' ); + + $localized = (string) wp_scripts()->get_data( 'ai_internal_links', 'data' ); + $this->assertStringContainsString( '"maxSuggestions":"3"', $localized ); + } +} diff --git a/tests/e2e-testing/e2e-testing.php b/tests/e2e-testing/e2e-testing.php index e403f727b..741013e6d 100644 --- a/tests/e2e-testing/e2e-testing.php +++ b/tests/e2e-testing/e2e-testing.php @@ -217,6 +217,32 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) { $response = str_replace( 'negative', 'neutral', $response ); $response = str_replace( '0.95', '0.5', $response ); } + } elseif ( is_string( $body ) && str_contains( $body, 'internal-linking assistant' ) ) { + $response = file_get_contents( __DIR__ . '/responses/OpenAI/internal-links-responses.json' ); + + $anchor_text = 'WordPress REST API'; + $title = 'Target Post Title'; + $target_url = 'http://localhost:8889/target-post/'; + + if ( preg_match( '/-\s+([^<]+?)\s+<(http[^>]+)>/i', $body, $matches ) ) { + $title = trim( $matches[1] ); + $target_url = trim( $matches[2] ); + } + + $json_data = wp_json_encode( + array( + 'suggestions' => array( + array( + 'anchor_text' => $anchor_text, + 'url' => $target_url, + 'title' => $title, + 'context' => 'Writers use automated tools to learn more about the WordPress REST API for content management.', + ), + ), + ) + ); + + $response = str_replace( 'REPLACE_SUGGESTIONS_JSON', addcslashes( $json_data, '"' ), $response ); } else { $response = file_get_contents( __DIR__ . '/responses/OpenAI/responses.json' ); } @@ -237,6 +263,32 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) { } elseif ( is_string( $body ) && str_contains( $body, 'permalink slug suggestions' ) ) { // Route slug-generation requests to their own fixture. $response = file_get_contents( __DIR__ . '/responses/OpenAI/slug-generation-completions.json' ); + } elseif ( is_string( $body ) && str_contains( $body, 'internal-linking assistant' ) ) { + $response = file_get_contents( __DIR__ . '/responses/OpenAI/completions.json' ); + + $anchor_text = 'WordPress REST API'; + $title = 'Target Post Title'; + $target_url = 'http://localhost:8889/target-post/'; + + if ( preg_match( '/-\s+([^<]+?)\s+<(http[^>]+)>/i', $body, $matches ) ) { + $title = trim( $matches[1] ); + $target_url = trim( $matches[2] ); + } + + $json_data = wp_json_encode( + array( + 'suggestions' => array( + array( + 'anchor_text' => $anchor_text, + 'url' => $target_url, + 'title' => $title, + 'context' => 'Writers use automated tools to learn more about the WordPress REST API for content management.', + ), + ), + ) + ); + + $response = str_replace( 'Edit or Delete Your First WordPress Post to Begin Your Blogging Adventure', addcslashes( $json_data, '"' ), $response ); } else { $response = file_get_contents( __DIR__ . '/responses/OpenAI/completions.json' ); } diff --git a/tests/e2e-testing/responses/OpenAI/internal-links-responses.json b/tests/e2e-testing/responses/OpenAI/internal-links-responses.json new file mode 100644 index 000000000..50a7f3886 --- /dev/null +++ b/tests/e2e-testing/responses/OpenAI/internal-links-responses.json @@ -0,0 +1,71 @@ +{ + "id": "resp_internal_links_e2e_mock_001", + "object": "response", + "created_at": 1771602524, + "status": "completed", + "background": false, + "billing": { + "payer": "developer" + }, + "completed_at": 1771602526, + "error": null, + "frequency_penalty": 0.0, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-2024-08-06", + "output": [ + { + "id": "msg_internal_links_e2e_mock_001", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "REPLACE_SUGGESTIONS_JSON" + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "presence_penalty": 0.0, + "previous_response_id": null, + "prompt_cache_key": null, + "prompt_cache_retention": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 0.5, + "text": { + "format": { + "type": "json_schema" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [], + "top_logprobs": 0, + "top_p": 1.0, + "truncation": "disabled", + "usage": { + "input_tokens": 490, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 540 + }, + "user": null, + "metadata": {} +} diff --git a/tests/e2e/specs/experiments/internal-links.spec.js b/tests/e2e/specs/experiments/internal-links.spec.js new file mode 100644 index 000000000..5b868bdc3 --- /dev/null +++ b/tests/e2e/specs/experiments/internal-links.spec.js @@ -0,0 +1,197 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + disableExperiment, + disableExperiments, + enableExperiment, + enableExperiments, + seedCredentials, +} = require( '../../utils/helpers' ); + +const EXPERIMENT_LABEL = 'Internal Link Suggestions'; + +const LONG_CONTENT = + 'Artificial intelligence is rapidly changing how content is created and published across the web today. Writers use automated tools to learn more about the WordPress REST API for content management. This paragraph provides enough characters for the internal link suggestions experiment to run properly because the feature requires a minimum content length before offering suggestions.'; + +test.describe( 'Internal Link Suggestions Experiment', () => { + test.beforeEach( async ( { requestUtils } ) => { + await seedCredentials( requestUtils ); + } ); + + test( 'Can enable the internal link suggestions experiment', async ( { + admin, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Internal Link Suggestions Experiment. + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + } ); + + test( 'Can use the Internal Link Suggestions Experiment in the block editor', async ( { + admin, + editor, + page, + requestUtils, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Internal Link Suggestions Experiment. + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + + // Create a target post so it is in the site index. + await requestUtils.createPost( { + title: 'Target Post for Internal Linking', + status: 'publish', + } ); + + // Create a new post to edit. + await admin.createNewPost( { + postType: 'post', + title: 'Test Internal Link Suggestions', + content: LONG_CONTENT, + } ); + + await editor.saveDraft(); + + // Open document settings sidebar. + await editor.openDocumentSettingsSidebar(); + + const suggestButton = page.getByRole( 'button', { + name: 'Suggest Internal Links', + } ); + + await expect( suggestButton ).toBeVisible(); + await expect( suggestButton ).toBeEnabled(); + + await suggestButton.click(); + + // Ensure suggestions list is displayed with header. + await expect( + page.locator( '.ai-internal-links__suggestions-header' ) + ).toBeVisible( { timeout: 15000 } ); + await expect( + page.locator( '.ai-internal-links__suggestions-header' ) + ).toHaveText( '1 suggestion(s) found.' ); + + const suggestionItem = page.locator( '.ai-internal-links__suggestion' ); + await expect( suggestionItem ).toBeVisible(); + await expect( suggestionItem ).toContainText( '"WordPress REST API"' ); + + // Accept the suggestion. + const acceptButton = suggestionItem.getByRole( 'button', { + name: 'Accept', + } ); + await expect( acceptButton ).toBeVisible(); + await acceptButton.click(); + + // Verify success notice. + await expect( + page.locator( '.components-snackbar__content', { + hasText: 'Internal link applied.', + } ) + ).toBeVisible(); + + // Save the post. + await editor.saveDraft(); + } ); + + test( 'Suggest Internal Links button is disabled when there is not enough content', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Internal Link Suggestions Experiment. + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + + // Create a new post with content below minimum length (<75 chars). + await admin.createNewPost( { + postType: 'post', + title: 'Test Internal Links Short Content', + content: 'Too short.', + } ); + + await editor.saveDraft(); + + // Open document settings sidebar. + await editor.openDocumentSettingsSidebar(); + + const suggestButton = page.getByRole( 'button', { + name: 'Suggest Internal Links', + } ); + + await expect( suggestButton ).toBeVisible(); + await expect( suggestButton ).toBeDisabled(); + + await expect( + page.locator( '.ai-internal-links__plugin-description' ) + ).toHaveText( + 'Internal Link Suggestions will be available when the post content has at least 75 characters.' + ); + } ); + + test( 'Ensure the Internal Link Suggestions Experiment UI is not visible when Experiments are globally disabled', async ( { + admin, + editor, + page, + } ) => { + // Enable the Internal Link Suggestions Experiment. + await enableExperiment( admin, page, EXPERIMENT_LABEL ); + + // Globally turn off Experiments. + await disableExperiments( admin, page ); + + await admin.createNewPost( { + postType: 'post', + title: 'Test Internal Links Globally Disabled', + content: LONG_CONTENT, + } ); + + await editor.saveDraft(); + + // Open document settings sidebar. + await editor.openDocumentSettingsSidebar(); + + await expect( + page.getByRole( 'button', { name: 'Suggest Internal Links' } ) + ).not.toBeVisible(); + } ); + + test( 'Ensure the Internal Link Suggestions Experiment UI is not visible when the experiment is disabled', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Disable the Internal Link Suggestions Experiment. + await disableExperiment( admin, page, EXPERIMENT_LABEL ); + + await admin.createNewPost( { + postType: 'post', + title: 'Test Internal Links Experiment Disabled', + content: LONG_CONTENT, + } ); + + await editor.saveDraft(); + + // Open document settings sidebar. + await editor.openDocumentSettingsSidebar(); + + await expect( + page.getByRole( 'button', { name: 'Suggest Internal Links' } ) + ).not.toBeVisible(); + } ); +} ); diff --git a/webpack.config.js b/webpack.config.js index 35db526e5..03d69eb86 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -115,6 +115,11 @@ module.exports = { 'src/experiments/suggest-reply', 'index.tsx' ), + 'experiments/internal-links': path.resolve( + process.cwd(), + 'src/experiments/internal-links', + 'index.tsx' + ), 'experiments/alt-text-generation': path.resolve( process.cwd(), 'src/experiments/alt-text-generation',