diff --git a/docs/experiments/type-ahead.md b/docs/experiments/type-ahead.md new file mode 100644 index 000000000..7ffaf656d --- /dev/null +++ b/docs/experiments/type-ahead.md @@ -0,0 +1,130 @@ +# Type Ahead + +## Summary + +The Type Ahead experiment adds inline ghost-text completions to the block editor. When enabled, supported blocks show AI suggestions at the caret that can be accepted fully or incrementally with keyboard shortcuts. + +## Overview + +### For End Users + +When enabled, type-ahead suggestions appear while writing in supported blocks: + +1. Suggestions are generated when the caret is at the end of a selected supported block. +2. Suggestions can appear in empty paragraph blocks as well as non-empty blocks. +3. In empty blocks, the Gutenberg placeholder (`Type / to choose a block`) is hidden while ghost text is visible to prevent overlap. +4. Keyboard shortcuts: + - `Tab`: accept the full suggestion. + - `Cmd/Ctrl + Right Arrow`: accept the next word. + - `Cmd/Ctrl + Shift + Right Arrow`: accept the next sentence. + - `Esc`: dismiss current suggestion. + - `Cmd/Ctrl + Space`: manual trigger. + +### For Developers + +The experiment has three parts: + +1. **Experiment Class** (`WordPress\AI\Experiments\Type_Ahead\Type_Ahead`): registers the ability, enqueues editor assets, and registers settings. +2. **Ability Class** (`WordPress\AI\Abilities\Type_Ahead\Type_Ahead`): validates input, builds prompt context, calls AI, and returns structured `{ suggestion, confidence }`. +3. **React Editor Integration** (`src/experiments/type-ahead/`): wraps supported blocks, tracks caret/context, requests suggestions, and renders ghost text. + +## Architecture & Implementation + +### Key Hooks & Entry Points + +`WordPress\AI\Experiments\Type_Ahead\Type_Ahead::register()` wires: + +- `wp_abilities_api_init` -> registers `ai/type-ahead`. +- `enqueue_block_assets` -> enqueues `experiments/type-ahead` JS and `experiments/type-ahead` CSS. + +Editor bootstrap: + +- `src/experiments/type-ahead/index.tsx` reads `window.aiTypeAheadData`, creates the allowed block set, and registers an `editor.BlockEdit` HOC (`withAITypeAhead`). + +### Assets & Data Flow + +1. **PHP side** + - Enqueues script: `experiments/type-ahead`. + - Enqueues stylesheet: `experiments/type-ahead`. + - Localizes `window.aiTypeAheadData` with: + - `enabled` + - `completionMode` + - `triggerDelay` + - `confidence` + - `maxWords` + - `showHeadings` + +2. **React side** + - `TypeAheadBlock` resolves block/editable DOM nodes and caret details. + - `useTypeAheadContext` extracts plain block content, neighboring context, and post ID. + - `useTypeAheadSuggestion` debounces requests and calls `runAbility( 'ai/type-ahead', input )`. + - Suggestions are rendered: + - inline ghost span when caret is not at end; + - overlay text when caret is at end. + - Accepted text is inserted and an `input` event is dispatched so Gutenberg persists the change. + +3. **Ability side** + - Truncates context fields to 5000 chars. + - Builds prompt JSON via `prepare_prompt_context()`. + - Uses JSON schema output (`suggestion`, `confidence`) and validates/parses response. + - Caches per `(block_content, preceding_text, mode, max_words)` for 45 seconds. + +### Trigger Behavior + +- Requests run only when: + - experiment is enabled, + - block type is allowed (`core/paragraph` plus optional `core/heading`), + - current block is selected, + - caret is at block end. +- Empty block content is allowed. +- In `word` mode, auto-trigger additionally requires punctuation/context from `shouldTriggerFromContext()`. +- Manual trigger (`Cmd/Ctrl + Space`) bypasses the `word` mode context gate. + +### Input Schema (Ability) + +```php +array( + 'post_id' => array( 'type' => 'integer' ), + 'block_content' => array( 'type' => 'string' ), + 'preceding_text' => array( 'type' => 'string' ), + 'following_text' => array( 'type' => 'string' ), + 'surrounding_context' => array( 'type' => 'string' ), + 'cursor_position' => array( 'type' => 'integer' ), + 'mode' => array( 'type' => 'string', 'enum' => array( 'word', 'sentence', 'paragraph', 'smart' ) ), + 'max_words' => array( 'type' => 'integer' ), + 'manual_trigger' => array( 'type' => 'boolean' ), +) +``` + +### Output Schema (Ability) + +```php +array( + 'type' => 'object', + 'properties' => array( + 'suggestion' => array( 'type' => 'string' ), + 'confidence' => array( 'type' => 'number' ), + 'cursor_position' => array( 'type' => 'integer' ), + ), +) +``` + +### Permissions + +The ability's `permission_callback` has two paths: + +- **With `post_id`:** requires existing post, `edit_post` capability, and `show_in_rest` post type. +- **Without `post_id`:** requires `edit_posts`. + +## Testing + +### Manual Testing + +1. Enable global experiments and **Type-ahead Text** in settings. +2. Open block editor and verify suggestions appear in: + - non-empty paragraphs; + - empty paragraph blocks. +3. In an empty paragraph with active ghost text, verify placeholder text does not overlap. +4. Verify keyboard controls (`Tab`, `Cmd/Ctrl + Right`, `Cmd/Ctrl + Shift + Right`, `Esc`, `Cmd/Ctrl + Space`). +5. Enable headings in settings and verify `core/heading` support. +6. In `word` mode, verify auto-trigger only happens in triggering contexts, while manual trigger still works. diff --git a/includes/Abilities/Type_Ahead/Type_Ahead.php b/includes/Abilities/Type_Ahead/Type_Ahead.php new file mode 100644 index 000000000..a58c5a333 --- /dev/null +++ b/includes/Abilities/Type_Ahead/Type_Ahead.php @@ -0,0 +1,378 @@ + 'object', + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'description' => esc_html__( 'Post ID used to gather additional context.', 'ai' ), + ), + 'block_content' => array( + 'type' => 'string', + 'description' => esc_html__( 'Full text content of the active block.', 'ai' ), + ), + 'preceding_text' => array( + 'type' => 'string', + 'description' => esc_html__( 'Text that appears before the caret within the block.', 'ai' ), + ), + 'following_text' => array( + 'type' => 'string', + 'description' => esc_html__( 'Text after the caret within the block.', 'ai' ), + ), + 'surrounding_context' => array( + 'type' => 'string', + 'description' => esc_html__( 'Neighboring block content for additional context.', 'ai' ), + ), + 'cursor_position' => array( + 'type' => 'integer', + 'description' => esc_html__( 'Caret offset within the block plain text.', 'ai' ), + ), + 'mode' => array( + 'type' => 'string', + 'enum' => self::MODES, + ), + 'max_words' => array( + 'type' => 'integer', + 'description' => esc_html__( 'Maximum number of words in the suggestion.', 'ai' ), + ), + 'manual_trigger' => array( + 'type' => 'boolean', + ), + ), + 'required' => array( 'block_content' ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function output_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'suggestion' => array( + 'type' => 'string', + 'description' => esc_html__( 'Suggested continuation.', 'ai' ), + ), + 'confidence' => array( + 'type' => 'number', + 'description' => esc_html__( 'Confidence score between 0 and 1.', 'ai' ), + ), + 'cursor_position' => array( + 'type' => 'integer', + ), + ), + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + * + * @return array{suggestion: string, confidence: float, cursor_position: int}|\WP_Error + */ + protected function execute_callback( $input ) { + $args = wp_parse_args( + $input, + array( + 'post_id' => null, + 'block_content' => '', + 'preceding_text' => '', + 'following_text' => '', + 'surrounding_context' => '', + 'cursor_position' => 0, + 'mode' => 'smart', + 'max_words' => 20, + 'manual_trigger' => false, + ) + ); + + $mode = in_array( $args['mode'], self::MODES, true ) ? $args['mode'] : 'smart'; + $max_words = max( 1, min( 50, absint( $args['max_words'] ) ) ); + + $block_content = $this->truncate_text( (string) $args['block_content'] ); + $preceding_text = $this->truncate_text( (string) $args['preceding_text'] ); + $following_text = $this->truncate_text( (string) $args['following_text'] ); + $surrounding = $this->truncate_text( (string) $args['surrounding_context'] ); + $cursor_position = absint( $args['cursor_position'] ); + + if ( $cursor_position > mb_strlen( wp_strip_all_tags( $block_content ) ) ) { + $cursor_position = mb_strlen( wp_strip_all_tags( $block_content ) ); + } + + $cache_key = $this->build_cache_key( $block_content, $preceding_text, $mode, $max_words ); + $cached = wp_cache_get( $cache_key, self::CACHE_GROUP ); + + if ( ! empty( $cached ) ) { + return $cached; + } + + $context = $this->prepare_prompt_context( $block_content, $preceding_text, $following_text, $surrounding, $cursor_position, $mode, $max_words, (bool) $args['manual_trigger'] ); + + $result = $this->generate_suggestion( $context ); + + if ( is_wp_error( $result ) ) { + return $result; + } + + $result['cursor_position'] = $cursor_position; + + wp_cache_set( $cache_key, $result, self::CACHE_GROUP, self::CACHE_TTL ); // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined + + return $result; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function permission_callback( $args ) { + $post_id = isset( $args['post_id'] ) ? absint( $args['post_id'] ) : null; + + if ( $post_id ) { + $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 request type-ahead suggestions for this post.', 'ai' ) + ); + } + + // Ensure the post type is allowed in REST endpoints. + $post_type = get_post_type( $post_id ); + + if ( ! $post_type ) { + return false; + } + + $post_type_obj = get_post_type_object( $post_type ); + + if ( ! $post_type_obj || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to request type-ahead suggestions.', 'ai' ) + ); + } + + return true; + } + + /** + * {@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 a type-ahead suggestion. + */ + private function suggestion_schema(): array { + return array( + 'type' => 'object', + 'properties' => array( + 'suggestion' => array( + 'type' => 'string', + ), + 'confidence' => array( + 'type' => 'number', + ), + ), + 'required' => array( 'suggestion', 'confidence' ), + 'additionalProperties' => false, + ); + } + + /** + * Builds a cache key for the request. + */ + private function build_cache_key( string $block_content, string $preceding_text, string $mode, int $max_words ): string { + return 'type_ahead_' . md5( $block_content . '|' . $preceding_text . '|' . $mode . '|' . $max_words ); + } + + /** + * Generates the suggestion via the AI client. + * + * @param array $context Prompt context payload. + * @return array{suggestion: string, confidence: float}|\WP_Error + */ + private function generate_suggestion( array $context ) { + $prompt = wp_json_encode( + $context, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + + if ( ! is_string( $prompt ) ) { + return new WP_Error( 'ai_type_ahead_invalid_prompt', esc_html__( 'Unable to encode the type-ahead prompt.', 'ai' ) ); + } + + $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 new WP_Error( 'ai_type_ahead_empty', esc_html__( 'The AI provider returned an empty suggestion.', 'ai' ) ); + } + + $decoded = json_decode( (string) $raw, true ); + + if ( ! is_array( $decoded ) || ! isset( $decoded['suggestion'] ) || ! is_string( $decoded['suggestion'] ) ) { + return new WP_Error( 'ai_type_ahead_invalid', esc_html__( 'Unable to parse the type-ahead suggestion response.', 'ai' ) ); + } + + $suggestion = sanitize_textarea_field( $decoded['suggestion'] ); + + if ( '' === $suggestion ) { + return new WP_Error( 'ai_type_ahead_blank', esc_html__( 'The suggestion returned was blank after sanitization.', 'ai' ) ); + } + + $confidence = isset( $decoded['confidence'] ) ? min( 1, max( 0, (float) $decoded['confidence'] ) ) : 0.0; + + return array( + 'suggestion' => $suggestion, + 'confidence' => $confidence, + ); + } + + /** + * Gets a prompt builder for generating type-ahead suggestions. + * + * @since x.x.x + * + * @param string $prompt The prompt to generate type-ahead suggestions from. + * @return \WP_AI_Client_Prompt_Builder|\WP_Error The prompt builder, or a 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() ) + ->using_model_preference( + array( 'anthropic', 'claude-haiku-4-5' ), + array( 'google', 'gemini-2.5-flash' ), + array( 'openai', 'gpt-4.1-nano' ) + ) + ->as_json_response( $this->suggestion_schema() ); + + return $this->ensure_text_generation_supported( + $prompt_builder, + esc_html__( 'Type-ahead suggestion generation failed. Please ensure you have a connected provider that supports text generation.', 'ai' ) + ); + } + + /** + * Prepares the structured context payload for the prompt. + * + * @param string $block_content Block content. + * @param string $preceding_text Text before caret. + * @param string $following_text Text after caret. + * @param string $surrounding_context Neighboring block text. + * @param int $cursor_position Caret offset. + * @param string $mode Completion mode. + * @param int $max_words Maximum words in suggestion. + * @param bool $manual_trigger Whether the user explicitly requested the suggestion. + * + * @return array + */ + private function prepare_prompt_context( string $block_content, string $preceding_text, string $following_text, string $surrounding_context, int $cursor_position, string $mode, int $max_words, bool $manual_trigger ): array { + return array( + 'mode' => $mode, + 'max_words' => $max_words, + 'cursor_position' => $cursor_position, + 'block_content' => $block_content, + 'preceding_text' => $preceding_text, + 'following_text' => $following_text, + 'surrounding_context' => $surrounding_context, + 'manual_trigger' => $manual_trigger, + ); + } + + /** + * Truncates text to the context limit. + */ + private function truncate_text( string $value ): string { + $value = normalize_content( $value ); + + if ( mb_strlen( $value ) > self::CONTEXT_LIMIT ) { + return mb_substr( $value, -1 * self::CONTEXT_LIMIT ); + } + + return $value; + } +} diff --git a/includes/Abilities/Type_Ahead/system-instruction.php b/includes/Abilities/Type_Ahead/system-instruction.php new file mode 100644 index 000000000..4ba2809a8 --- /dev/null +++ b/includes/Abilities/Type_Ahead/system-instruction.php @@ -0,0 +1,22 @@ + + */ + private const DEFAULTS = array( // phpcs:ignore SlevomatCodingStandard.Classes.DisallowMultiConstantDefinition -- This is used as an array const. + 'mode' => 'smart', + 'delay' => 500, + 'confidence' => 70, + 'max_words' => 20, + 'headings' => false, + ); + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public static function get_id(): string { + return 'type-ahead'; + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + protected function load_metadata(): array { + return array( + 'label' => __( 'Type-ahead Text', 'ai' ), + 'description' => __( 'Ghost text suggestions while writing paragraphs in the block editor. Requires an AI connector that includes support for text generation models.', 'ai' ), + 'category' => Experiment_Category::EDITOR, + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + add_action( 'wp_abilities_api_init', array( $this, 'register_abilities' ) ); + add_action( 'enqueue_block_assets', array( $this, 'enqueue_assets' ) ); + } + + /** + * Registers the type-ahead 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' => Type_Ahead_Ability::class, + ) + ); + } + + /** + * Enqueues and localizes the editor assets. + * + * @since x.x.x + */ + public function enqueue_assets(): void { + Asset_Loader::enqueue_script( 'type_ahead', 'experiments/type-ahead' ); + Asset_Loader::enqueue_style( 'type_ahead', 'experiments/type-ahead' ); + + $settings = $this->get_settings(); + + Asset_Loader::localize_script( + 'type_ahead', + 'TypeAheadData', + array( + 'enabled' => $this->is_enabled(), + 'completionMode' => $settings['mode'], + 'triggerDelay' => (int) $settings['delay'], + 'confidence' => (float) $settings['confidence'] / 100, + 'maxWords' => (int) $settings['max_words'], + 'showHeadings' => (bool) $settings['headings'], + ) + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'mode' ), + array( + 'type' => 'string', + 'default' => self::DEFAULTS['mode'], + 'sanitize_callback' => array( $this, 'sanitize_mode' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'string', + 'enum' => array( 'word', 'sentence', 'paragraph', 'smart' ), + ), + ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'delay' ), + array( + 'type' => 'integer', + 'default' => self::DEFAULTS['delay'], + 'sanitize_callback' => array( $this, 'sanitize_delay' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'integer', + 'minimum' => 200, + 'maximum' => 2000, + ), + ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'confidence' ), + array( + 'type' => 'integer', + 'default' => self::DEFAULTS['confidence'], + 'sanitize_callback' => array( $this, 'sanitize_confidence' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'integer', + 'minimum' => 0, + 'maximum' => 100, + ), + ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'max_words' ), + array( + 'type' => 'integer', + 'default' => self::DEFAULTS['max_words'], + 'sanitize_callback' => array( $this, 'sanitize_max_words' ), + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 50, + ), + ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + $this->get_field_option_name( 'headings' ), + array( + 'type' => 'boolean', + 'default' => self::DEFAULTS['headings'], + 'sanitize_callback' => 'rest_sanitize_boolean', + 'show_in_rest' => array( + 'schema' => array( + 'type' => 'boolean', + ), + ), + ) + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function get_settings_fields(): array { + return array( + array( + 'id' => 'mode', + 'label' => __( 'Completion mode', 'ai' ), + 'type' => 'text', + 'default' => self::DEFAULTS['mode'], + 'elements' => array( + array( + 'value' => 'word', + 'label' => __( 'Word', 'ai' ), + ), + array( + 'value' => 'sentence', + 'label' => __( 'Sentence', 'ai' ), + ), + array( + 'value' => 'paragraph', + 'label' => __( 'Paragraph', 'ai' ), + ), + array( + 'value' => 'smart', + 'label' => __( 'Smart', 'ai' ), + ), + ), + ), + array( + 'id' => 'delay', + 'label' => __( 'Trigger delay (ms)', 'ai' ), + 'description' => __( 'The time you must pause at the end of a paragraph block before a suggestion is generated. If you start typing after the request has triggered but prior to the suggestion being returned, the suggestion won\'t be shown but because the API request has already been made, you will be charged for the request.', 'ai' ), + 'type' => 'integer', + 'default' => self::DEFAULTS['delay'], + 'isValid' => array( + 'min' => 200, + 'max' => 2000, + ), + ), + array( + 'id' => 'confidence', + 'label' => __( 'Minimum confidence (%)', 'ai' ), + 'type' => 'integer', + 'default' => self::DEFAULTS['confidence'], + 'isValid' => array( + 'min' => 0, + 'max' => 100, + ), + ), + array( + 'id' => 'max_words', + 'label' => __( 'Max words per suggestion', 'ai' ), + 'type' => 'integer', + 'default' => self::DEFAULTS['max_words'], + 'isValid' => array( + 'min' => 1, + 'max' => 50, + ), + ), + array( + 'id' => 'headings', + 'label' => __( 'Enable in headings', 'ai' ), + 'type' => 'boolean', + 'default' => self::DEFAULTS['headings'], + ), + ); + } + + /** + * Returns the saved settings merged with defaults. + * + * @since x.x.x + * + * @return array + */ + private function get_settings(): array { + $settings = array(); + + foreach ( self::DEFAULTS as $key => $value ) { + $settings[ $key ] = get_option( $this->get_field_option_name( $key ), $value ); + } + + return $settings; + } + + /** + * Sanitizes the completion mode. + * + * @since x.x.x + * + * @param string $mode The completion mode to sanitize. + * @return string The sanitized completion mode. + */ + public function sanitize_mode( ?string $mode ): string { + $mode = is_string( $mode ) ? strtolower( $mode ) : ''; + + return in_array( $mode, array( 'word', 'sentence', 'paragraph', 'smart' ), true ) ? $mode : self::DEFAULTS['mode']; + } + + /** + * Sanitizes the delay field. + * + * @since x.x.x + * + * @param int $value The value to sanitize. + * @return int The sanitized delay value. + */ + public function sanitize_delay( ?int $value ): int { + $value = (int) $value; + + return max( 200, min( 2000, $value ) ); + } + + /** + * Sanitizes the confidence field. + * + * @since x.x.x + * + * @param int $value The value to sanitize. + * @return int The sanitized confidence value. + */ + public function sanitize_confidence( ?int $value ): int { + $value = (int) $value; + + return max( 0, min( 100, $value ) ); + } + + /** + * Sanitizes the max words field. + * + * @since x.x.x + * + * @param int $value The value to sanitize. + * @return int The sanitized max words value. + */ + public function sanitize_max_words( ?int $value ): int { + $value = (int) $value; + + return max( 1, min( 50, $value ) ); + } +} diff --git a/src/experiments/type-ahead/components/TypeAheadBlock.tsx b/src/experiments/type-ahead/components/TypeAheadBlock.tsx new file mode 100644 index 000000000..c66e86af5 --- /dev/null +++ b/src/experiments/type-ahead/components/TypeAheadBlock.tsx @@ -0,0 +1,316 @@ +/** + * Components for type-ahead block. + */ + +/** + * External dependencies + */ +import type { ComponentType } from 'react'; + +/** + * WordPress dependencies + */ +import { VisuallyHidden } from '@wordpress/components'; +import { useCallback, useEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TypeAheadSettings } from '../types'; +import { splitSuggestion } from '../utils/text'; +import { useBlockDom } from '../hooks/useBlockDom'; +import { useCaretData } from '../hooks/useCaretData'; +import { useTypeAheadContext } from '../hooks/useTypeAheadContext'; +import { useTypeAheadSuggestion } from '../hooks/useTypeAheadSuggestion'; +import TypeAheadOverlay from './TypeAheadOverlay'; + +type TypeAheadBlockProps = { + BlockEdit: ComponentType< any >; + blockProps: any; + settings: TypeAheadSettings; + allowedBlocks: Set< string >; +}; + +/** + * Block wrapper that adds type-ahead behavior to supported blocks. + * + * @param {Object} props Block wrapper props. + * @param {ComponentType< any >} props.BlockEdit Block edit component. + * @param {any} props.blockProps Block wrapper props. + * @param {TypeAheadSettings} props.settings Type-ahead settings. + * @param {Set< string >} props.allowedBlocks Allowed blocks. + * @return {React.JSX.Element} Wrapped block edit UI plus ghost text overlay. + */ +const TypeAheadBlock = ( { + BlockEdit, + blockProps, + settings, + allowedBlocks, +}: TypeAheadBlockProps ): React.JSX.Element => { + const { clientId, attributes, name } = blockProps; + const { block, editable } = useBlockDom( clientId ); + const caret = useCaretData( editable ); + const { selectedClientId, siblingContext, postId, plainContent } = + useTypeAheadContext( clientId, attributes?.content || '' ); + const followingText = caret ? plainContent.slice( caret.offset ) : ''; + const caretAtEnd = caret ? followingText.length === 0 : false; + + const shouldRequest = + settings.enabled && + allowedBlocks.has( name ) && + selectedClientId === clientId && + caretAtEnd; + + const { + suggestion, + setSuggestion, + cancelPendingRequest, + triggerManualFetch, + } = useTypeAheadSuggestion( { + shouldRequest, + caret, + editable, + plainContent, + followingText, + siblingContext, + postId, + clientId, + settings, + } ); + + useEffect( () => { + if ( ! editable ) { + return; + } + + const handleInput = () => { + cancelPendingRequest(); + setSuggestion( null ); + }; + + editable.addEventListener( 'input', handleInput ); + return () => { + editable.removeEventListener( 'input', handleInput ); + }; + }, [ editable, cancelPendingRequest, setSuggestion ] ); + + useEffect( () => { + if ( ! block ) { + return; + } + + block.classList.add( 'ai-type-ahead-block' ); + return () => block.classList.remove( 'ai-type-ahead-block' ); + }, [ block ] ); + + useEffect( () => { + if ( ! editable ) { + return; + } + + const isEmptyBlock = + editable.getAttribute( 'data-empty' ) === 'true' || + plainContent.trim().length === 0; + const shouldHidePlaceholder = + Boolean( suggestion?.text ) && isEmptyBlock; + + editable.classList.toggle( + 'ai-type-ahead-hide-placeholder', + shouldHidePlaceholder + ); + + return () => { + editable.classList.remove( 'ai-type-ahead-hide-placeholder' ); + }; + }, [ editable, suggestion?.text, plainContent ] ); + + useEffect( () => { + if ( ! editable ) { + return; + } + + const removeInlineGhost = () => { + editable + .querySelectorAll( '.ai-type-ahead-inline-ghost' ) + .forEach( ( node ) => node.remove() ); + }; + + removeInlineGhost(); + + if ( ! suggestion?.text || caretAtEnd || ! caret ) { + return removeInlineGhost; + } + + const doc = caret.ownerDocument; + const selection = doc.getSelection(); + if ( ! selection || selection.rangeCount === 0 ) { + return removeInlineGhost; + } + + const range = selection.getRangeAt( 0 ); + if ( ! editable.contains( range.startContainer ) ) { + return removeInlineGhost; + } + + const ghost = doc.createElement( 'span' ); + ghost.className = 'ai-type-ahead-inline-ghost'; + ghost.setAttribute( 'contenteditable', 'false' ); + ghost.setAttribute( 'aria-hidden', 'true' ); + ghost.textContent = suggestion.text; + + range.insertNode( ghost ); + + // Keep caret at original insertion point, before ghost content. + const caretRange = doc.createRange(); + caretRange.setStartBefore( ghost ); + caretRange.collapse( true ); + selection.removeAllRanges(); + selection.addRange( caretRange ); + + return removeInlineGhost; + }, [ editable, suggestion?.text, caretAtEnd, caret ] ); + + const insertText = useCallback( + ( text: string ) => { + if ( ! caret || ! editable ) { + return; + } + + const doc = caret.ownerDocument; + const selection = doc.getSelection(); + if ( ! selection || selection.rangeCount === 0 ) { + return; + } + + const range = selection.getRangeAt( 0 ); + if ( ! editable.contains( range.startContainer ) ) { + return; + } + + if ( ! range.collapsed ) { + range.deleteContents(); + } + + const textNode = doc.createTextNode( text ); + range.insertNode( textNode ); + range.setStartAfter( textNode ); + range.collapse( true ); + selection.removeAllRanges(); + selection.addRange( range ); + + const init: InputEventInit = { + bubbles: true, + cancelable: false, + data: text, + inputType: 'insertText', + }; + + try { + const inputEvent = new InputEvent( 'input', init ); + editable.dispatchEvent( inputEvent ); + } catch { + const fallbackEvent = doc.createEvent( 'HTMLEvents' ); + fallbackEvent.initEvent( 'input', true, false ); + editable.dispatchEvent( fallbackEvent ); + } + }, + [ caret, editable ] + ); + + const acceptSuggestion = useCallback( + ( mode: 'word' | 'sentence' | 'all' ) => { + if ( ! suggestion ) { + return; + } + + const { apply, remainder } = splitSuggestion( + suggestion.text, + mode + ); + if ( ! apply ) { + return; + } + + insertText( apply ); + + if ( remainder.trim() ) { + setSuggestion( { + text: remainder, + confidence: suggestion.confidence, + } ); + return; + } + + setSuggestion( null ); + }, + [ insertText, setSuggestion, suggestion ] + ); + + useEffect( () => { + if ( ! editable ) { + return; + } + + const handleKeyDown = ( event: KeyboardEvent ) => { + if ( suggestion && event.key === 'Tab' && ! event.shiftKey ) { + event.preventDefault(); + acceptSuggestion( 'all' ); + return; + } + + if ( + suggestion && + event.key === 'ArrowRight' && + ( event.metaKey || event.ctrlKey ) + ) { + event.preventDefault(); + acceptSuggestion( event.shiftKey ? 'sentence' : 'word' ); + return; + } + + if ( suggestion && event.key === 'Escape' ) { + event.preventDefault(); + setSuggestion( null ); + return; + } + + if ( + ( event.metaKey || event.ctrlKey ) && + event.code === 'Space' + ) { + event.preventDefault(); + triggerManualFetch(); + } + }; + + editable.addEventListener( 'keydown', handleKeyDown ); + return () => editable.removeEventListener( 'keydown', handleKeyDown ); + }, [ + editable, + suggestion, + acceptSuggestion, + setSuggestion, + triggerManualFetch, + ] ); + + if ( ! allowedBlocks.has( name ) ) { + return ; + } + + return ( + <> + + + + { suggestion?.text ?? '' } + + + ); +}; + +export default TypeAheadBlock; diff --git a/src/experiments/type-ahead/components/TypeAheadOverlay.tsx b/src/experiments/type-ahead/components/TypeAheadOverlay.tsx new file mode 100644 index 000000000..c5add1970 --- /dev/null +++ b/src/experiments/type-ahead/components/TypeAheadOverlay.tsx @@ -0,0 +1,80 @@ +/** + * Components for type-ahead overlay. + */ + +/** + * External dependencies + */ +import type { CSSProperties } from 'react'; + +/** + * WordPress dependencies + */ +import { createPortal, useEffect, useState } from '@wordpress/element'; + +type TypeAheadOverlayProps = { + ownerDocument: Document | null; + rect: DOMRect | null; + container: HTMLElement | null; + text: string | null; +}; + +/** + * Portal-rendered ghost text anchored to the caret line. + * + * @param {Object} props Overlay display state. + * @param {Document} props.ownerDocument Owner document. + * @param {DOMRect} props.rect Rect. + * @param {HTMLElement} props.container Container. + * @param {string} props.text Text. + * @return {React.JSX.Element | null} Overlay element when suggestion and caret are available. + */ +const TypeAheadOverlay = ( { + ownerDocument, + rect, + container, + text, +}: TypeAheadOverlayProps ): React.JSX.Element | null => { + const [ style, setStyle ] = useState< CSSProperties | null >( null ); + const body = ownerDocument?.body ?? document.body; + const win = ownerDocument?.defaultView ?? window; + + useEffect( () => { + if ( ! rect || ! body ) { + setStyle( null ); + return; + } + + const scrollX = win?.scrollX ?? win?.pageXOffset ?? 0; + const scrollY = win?.scrollY ?? win?.pageYOffset ?? 0; + const containerRect = container?.getBoundingClientRect() ?? null; + const containerLeft = containerRect?.left ?? rect.left; + const indent = Math.max( 0, rect.left - containerLeft ); + + setStyle( { + position: 'absolute', + zIndex: 1, + top: rect.top + scrollY, + left: containerLeft + scrollX, + width: containerRect?.width ?? 'auto', + textIndent: indent ? `${ indent }px` : undefined, + } ); + }, [ body, rect, win, container ] ); + + if ( ! body || ! rect || ! text || ! style ) { + return null; + } + + return createPortal( + , + body + ); +}; + +export default TypeAheadOverlay; diff --git a/src/experiments/type-ahead/constants.ts b/src/experiments/type-ahead/constants.ts new file mode 100644 index 000000000..675f1d374 --- /dev/null +++ b/src/experiments/type-ahead/constants.ts @@ -0,0 +1,8 @@ +/** + * Constants for type-ahead. + */ + +export const ALLOWED_BLOCKS = [ 'core/paragraph' ]; +export const REQUEST_TIMEOUT_MS = 15000; +export const WHITESPACE_REGEX = /\s/; +export const LEADING_WHITESPACE_REGEX = /^\s/; diff --git a/src/experiments/type-ahead/hooks/useBlockDom.ts b/src/experiments/type-ahead/hooks/useBlockDom.ts new file mode 100644 index 000000000..bf35052ab --- /dev/null +++ b/src/experiments/type-ahead/hooks/useBlockDom.ts @@ -0,0 +1,132 @@ +/** + * Hooks for block DOM. + */ + +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; + +type BlockDomState = { + block: HTMLElement | null; + editable: HTMLElement | null; +}; + +/** + * Locates the rendered block element and rich text editable element. + * + * @param clientId Block client ID. + * @return Current block and editable nodes. + */ +export const useBlockDom = ( clientId: string ): BlockDomState => { + const [ state, setState ] = useState< BlockDomState >( { + block: null, + editable: null, + } ); + + useEffect( () => { + let cancelled = false; + let rafId: number | null = null; + const observedDocs = new Set< Document >(); + + const queryDocuments = (): Document[] => { + const docs: Document[] = [ document ]; + + document + .querySelectorAll( + 'iframe[name="editor-canvas"], iframe.wp-block-editor-iframe__iframe' + ) + .forEach( ( frame ) => { + if ( + frame instanceof HTMLIFrameElement && + frame.contentDocument + ) { + docs.push( frame.contentDocument ); + } + } ); + + return docs; + }; + + const findEditable = ( blockEl: HTMLElement ): HTMLElement | null => { + if ( + blockEl.getAttribute( 'contenteditable' ) === 'true' || + blockEl.hasAttribute( 'data-rich-text-editable' ) + ) { + return blockEl; + } + + const candidates = Array.from( + blockEl.querySelectorAll< HTMLElement >( + '[data-rich-text-editable], [contenteditable]' + ) + ); + + return ( + candidates.find( + ( candidate ) => + candidate.getAttribute( 'contenteditable' ) !== 'false' + ) ?? null + ); + }; + + const lookup = () => { + const selector = `[data-block="${ clientId }"]`; + for ( const doc of queryDocuments() ) { + const block = doc.querySelector< HTMLElement >( selector ); + if ( block ) { + const editable = findEditable( block ); + if ( ! cancelled ) { + setState( { block, editable } ); + } + return; + } + } + + if ( ! cancelled ) { + setState( { block: null, editable: null } ); + } + }; + + const scheduleLookup = () => { + if ( cancelled || rafId !== null ) { + return; + } + rafId = requestAnimationFrame( () => { + rafId = null; + if ( ! cancelled ) { + ensureIframeObservation(); + lookup(); + } + } ); + }; + + const observer = new MutationObserver( scheduleLookup ); + const observerOptions: MutationObserverInit = { + childList: true, + subtree: true, + }; + + const ensureIframeObservation = () => { + for ( const doc of queryDocuments() ) { + if ( ! observedDocs.has( doc ) && doc.body ) { + observedDocs.add( doc ); + observer.observe( doc.body, observerOptions ); + } + } + }; + + lookup(); + ensureIframeObservation(); + + return () => { + cancelled = true; + observer.disconnect(); + if ( rafId !== null ) { + cancelAnimationFrame( rafId ); + } + }; + }, [ clientId ] ); + + return state; +}; diff --git a/src/experiments/type-ahead/hooks/useCaretData.ts b/src/experiments/type-ahead/hooks/useCaretData.ts new file mode 100644 index 000000000..d7b5da398 --- /dev/null +++ b/src/experiments/type-ahead/hooks/useCaretData.ts @@ -0,0 +1,116 @@ +/** + * Hooks for caret data. + */ + +/** + * WordPress dependencies + */ +import { useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { CaretData } from '../types'; + +/** + * Tracks caret position and nearby text details for a contenteditable element. + * + * @param {HTMLElement | null} editable Rich text editable element. + * @return {CaretData | null} Caret metadata when selection is inside the editable. + */ +export const useCaretData = ( + editable: HTMLElement | null +): CaretData | null => { + const [ caret, setCaret ] = useState< CaretData | null >( null ); + + useEffect( () => { + if ( ! editable ) { + setCaret( null ); + return; + } + + const doc = editable.ownerDocument || document; + const win = doc.defaultView || window; + const viewport = win?.visualViewport; + + const update = () => { + const selection = doc.getSelection(); + if ( ! selection || selection.rangeCount === 0 ) { + setCaret( null ); + return; + } + + const range = selection.getRangeAt( 0 ); + if ( ! editable.contains( range.startContainer ) ) { + setCaret( null ); + return; + } + + const markerRange = range.cloneRange(); + const rects = markerRange.getClientRects(); + const rect = + rects.item( rects.length - 1 ) ?? + markerRange.getBoundingClientRect(); + + const textRange = doc.createRange(); + textRange.selectNodeContents( editable ); + textRange.setEnd( range.startContainer, range.startOffset ); + + const precedingText = textRange.toString(); + setCaret( { + offset: precedingText.length, + rect, + precedingText, + ownerDocument: doc, + } ); + }; + + update(); + + const events: Array< keyof DocumentEventMap > = [ 'selectionchange' ]; + const elementEvents: Array< keyof HTMLElementEventMap > = [ + 'keyup', + 'mouseup', + 'input', + ]; + + const handleScroll = () => update(); + const handleResize = () => update(); + const handleViewportChange = () => update(); + const ResizeObserverCtor = win?.ResizeObserver ?? window.ResizeObserver; + let resizeObserver: ResizeObserver | null = null; + + events.forEach( ( eventName ) => + doc.addEventListener( eventName, update ) + ); + elementEvents.forEach( ( eventName ) => + editable.addEventListener( eventName, update ) + ); + + doc.addEventListener( 'scroll', handleScroll, true ); + win?.addEventListener( 'resize', handleResize ); + viewport?.addEventListener( 'resize', handleViewportChange ); + viewport?.addEventListener( 'scroll', handleViewportChange ); + + if ( ResizeObserverCtor ) { + resizeObserver = new ResizeObserverCtor( () => update() ); + resizeObserver.observe( editable ); + } + + return () => { + events.forEach( ( eventName ) => + doc.removeEventListener( eventName, update ) + ); + elementEvents.forEach( ( eventName ) => + editable.removeEventListener( eventName, update ) + ); + doc.removeEventListener( 'scroll', handleScroll, true ); + win?.removeEventListener( 'resize', handleResize ); + viewport?.removeEventListener( 'resize', handleViewportChange ); + viewport?.removeEventListener( 'scroll', handleViewportChange ); + resizeObserver?.disconnect(); + }; + }, [ editable ] ); + + return caret; +}; diff --git a/src/experiments/type-ahead/hooks/useTypeAheadContext.ts b/src/experiments/type-ahead/hooks/useTypeAheadContext.ts new file mode 100644 index 000000000..b951ca7b5 --- /dev/null +++ b/src/experiments/type-ahead/hooks/useTypeAheadContext.ts @@ -0,0 +1,89 @@ +/** + * Hooks for type-ahead context. + */ + +/** + * WordPress dependencies + */ +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { useSelect } from '@wordpress/data'; +import { store as editorStore } from '@wordpress/editor'; +import { useMemo } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { htmlToPlainText } from '../utils/text'; + +type TypeAheadContext = { + selectedClientId: string | null; + siblingContext: string; + postId: number; + plainContent: string; +}; + +/** + * Collects editor context used by type-ahead requests. + * + * @param {string} clientId Current block client ID. + * @param {string} htmlContent Current block HTML content. + * @return {TypeAheadContext} Block selection state and neighboring text context. + */ +export const useTypeAheadContext = ( + clientId: string, + htmlContent: string +): TypeAheadContext => { + const { selectedClientId, siblingContext, postId } = useSelect( + ( select ) => { + const blockEditor = select( blockEditorStore ); + const editor = select( editorStore ); + const selected = blockEditor.getSelectedBlockClientId(); + const rootClientId = blockEditor.getBlockRootClientId( clientId ); + const order = blockEditor.getBlockOrder( + rootClientId || undefined + ); + const index = blockEditor.getBlockIndex( clientId ); + const hasOrder = Array.isArray( order ) && order.length > 0; + const previousId = + hasOrder && index > 0 ? order[ index - 1 ] : null; + const nextId = + hasOrder && index !== -1 && index < order.length - 1 + ? order[ index + 1 ] + : null; + const previous = previousId + ? blockEditor.getBlockAttributes( previousId ) + : null; + const next = nextId + ? blockEditor.getBlockAttributes( nextId ) + : null; + + const neighborText = [ + previous?.[ 'content' ], // eslint-disable-line dot-notation + next?.[ 'content' ], // eslint-disable-line dot-notation + ] + .filter( Boolean ) + .map( ( value ) => htmlToPlainText( value as string ) ) + .join( '\n\n' ) + .trim(); + + return { + selectedClientId: selected, + siblingContext: neighborText, + postId: Number( editor.getCurrentPostId() || 0 ), + }; + }, + [ clientId ] + ); + + const plainContent = useMemo( + () => htmlToPlainText( htmlContent || '' ), + [ htmlContent ] + ); + + return { + selectedClientId, + siblingContext, + postId, + plainContent, + }; +}; diff --git a/src/experiments/type-ahead/hooks/useTypeAheadSuggestion.ts b/src/experiments/type-ahead/hooks/useTypeAheadSuggestion.ts new file mode 100644 index 000000000..a4aa29aac --- /dev/null +++ b/src/experiments/type-ahead/hooks/useTypeAheadSuggestion.ts @@ -0,0 +1,307 @@ +/** + * Hooks for type-ahead suggestion. + */ + +/** + * WordPress dependencies + */ +import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import { REQUEST_TIMEOUT_MS } from '../constants'; +import type { + CaretData, + Suggestion, + TypeAheadAbilityInput, + TypeAheadResponse, + TypeAheadSettings, +} from '../types'; +import { + addLeadingSpaceIfNeeded, + shouldTriggerFromContext, +} from '../utils/text'; +import { runAbility } from '../../../utils/run-ability'; + +type UseTypeAheadSuggestionArgs = { + shouldRequest: boolean; + caret: CaretData | null; + editable: HTMLElement | null; + plainContent: string; + followingText: string; + siblingContext: string; + postId: number; + clientId: string; + settings: TypeAheadSettings; +}; + +type UseTypeAheadSuggestionResult = { + suggestion: Suggestion | null; + setSuggestion: ( next: Suggestion | null ) => void; + cancelPendingRequest: () => void; + triggerManualFetch: () => void; +}; + +/** + * Handles request scheduling and fetching for block type-ahead suggestions. + * + * @param args Type-ahead request context. + * @return Suggestion state and control handlers. + */ +export const useTypeAheadSuggestion = ( + args: UseTypeAheadSuggestionArgs +): UseTypeAheadSuggestionResult => { + const { + shouldRequest, + caret, + editable, + plainContent, + followingText, + siblingContext, + postId, + clientId, + settings, + } = args; + const [ suggestion, setSuggestionState ] = useState< Suggestion | null >( + null + ); + const requestRef = useRef( 0 ); + const abortControllerRef = useRef< AbortController | null >( null ); + const debounceTimerRef = useRef< number | null >( null ); + const requestTimeoutRef = useRef< number | null >( null ); + const requestSourceRef = useRef< 'auto' | 'manual' | null >( null ); + const manualSuggestionActiveRef = useRef( false ); + + const stateRef = useRef( { + caret, + plainContent, + followingText, + siblingContext, + postId, + clientId, + completionMode: settings.completionMode, + confidence: settings.confidence, + maxWords: settings.maxWords, + } ); + + useEffect( () => { + stateRef.current = { + caret, + plainContent, + followingText, + siblingContext, + postId, + clientId, + completionMode: settings.completionMode, + confidence: settings.confidence, + maxWords: settings.maxWords, + }; + }, [ + caret, + plainContent, + followingText, + siblingContext, + postId, + clientId, + settings.completionMode, + settings.confidence, + settings.maxWords, + ] ); + + const clearRequestTimeout = useCallback( () => { + if ( requestTimeoutRef.current !== null ) { + window.clearTimeout( requestTimeoutRef.current ); + requestTimeoutRef.current = null; + } + }, [] ); + + const cancelPendingRequest = useCallback( () => { + if ( debounceTimerRef.current !== null ) { + window.clearTimeout( debounceTimerRef.current ); + debounceTimerRef.current = null; + } + + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + + clearRequestTimeout(); + requestSourceRef.current = null; + }, [ clearRequestTimeout ] ); + + const setSuggestion = useCallback( ( next: Suggestion | null ) => { + if ( ! next ) { + manualSuggestionActiveRef.current = false; + } + setSuggestionState( next ); + }, [] ); + + const fetchSuggestion = useCallback( + async ( manual: boolean ) => { + const state = stateRef.current; + + if ( ! state.caret ) { + return; + } + + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + clearRequestTimeout(); + + const controller = new AbortController(); + requestSourceRef.current = manual ? 'manual' : 'auto'; + abortControllerRef.current = controller; + const currentRequest = ++requestRef.current; + requestTimeoutRef.current = window.setTimeout( () => { + controller.abort(); + }, REQUEST_TIMEOUT_MS ); + + const input: TypeAheadAbilityInput = { + post_id: state.postId, + block_content: state.plainContent, + preceding_text: state.caret.precedingText, + following_text: state.followingText, + surrounding_context: state.siblingContext, + cursor_position: state.caret.offset, + mode: state.completionMode, + max_words: state.maxWords, + manual_trigger: manual, + }; + + try { + const response = await runAbility< TypeAheadResponse >( + 'ai/type-ahead', + input, + { signal: controller.signal } + ); + + if ( currentRequest !== requestRef.current ) { + return; + } + + if ( ! response?.suggestion ) { + setSuggestion( null ); + return; + } + + if ( + typeof response.confidence === 'number' && + response.confidence < state.confidence + ) { + setSuggestion( null ); + return; + } + + const precedingText = + stateRef.current.caret?.precedingText ?? ''; + const normalizedText = addLeadingSpaceIfNeeded( + String( response.suggestion ), + precedingText + ); + manualSuggestionActiveRef.current = manual; + + setSuggestion( { + text: normalizedText, + confidence: Number( response.confidence || 0 ), + } ); + } catch ( error: unknown ) { + if ( + error instanceof DOMException && + error.name === 'AbortError' + ) { + return; + } + + // eslint-disable-next-line no-console + console.error( '[AI Type Ahead] Request failed', error ); + setSuggestion( null ); + } finally { + if ( abortControllerRef.current === controller ) { + abortControllerRef.current = null; + } + if ( currentRequest === requestRef.current ) { + requestSourceRef.current = null; + } + clearRequestTimeout(); + } + }, + [ clearRequestTimeout, setSuggestion ] + ); + + const scheduleFetch = useCallback( + ( manual: boolean ) => { + if ( debounceTimerRef.current !== null ) { + window.clearTimeout( debounceTimerRef.current ); + } + + if ( manual ) { + requestSourceRef.current = 'manual'; + fetchSuggestion( true ); + return; + } + + const delay = Math.max( 200, settings.triggerDelay || 500 ); + requestSourceRef.current = 'auto'; + debounceTimerRef.current = window.setTimeout( () => { + debounceTimerRef.current = null; + + const state = stateRef.current; + const contextTriggered = state.caret + ? shouldTriggerFromContext( state.caret.precedingText ) + : false; + + if ( state.completionMode === 'word' && ! contextTriggered ) { + return; + } + + fetchSuggestion( false ); + }, delay ); + }, + [ fetchSuggestion, settings.triggerDelay ] + ); + + useEffect( () => { + const preserveManualSuggestion = + ! shouldRequest && manualSuggestionActiveRef.current; + + if ( ! shouldRequest || ! caret || ! editable ) { + if ( requestSourceRef.current !== 'manual' ) { + cancelPendingRequest(); + } + if ( ! preserveManualSuggestion ) { + setSuggestion( null ); + } + return; + } + + scheduleFetch( false ); + return () => { + if ( requestSourceRef.current !== 'manual' ) { + cancelPendingRequest(); + } + }; + }, [ + shouldRequest, + caret?.offset, + plainContent, + cancelPendingRequest, + scheduleFetch, + caret, + editable, + setSuggestion, + ] ); + + const triggerManualFetch = useCallback( () => { + scheduleFetch( true ); + }, [ scheduleFetch ] ); + + return { + suggestion, + setSuggestion, + cancelPendingRequest, + triggerManualFetch, + }; +}; diff --git a/src/experiments/type-ahead/index.scss b/src/experiments/type-ahead/index.scss new file mode 100644 index 000000000..4e0936f53 --- /dev/null +++ b/src/experiments/type-ahead/index.scss @@ -0,0 +1,36 @@ +.ai-type-ahead-block { + position: relative; +} + +.ai-type-ahead-overlay { + position: absolute; + display: block; + pointer-events: none; + color: var(--ai-type-ahead-ghost-color, #8a8f98); + opacity: 1; + font-style: normal; + font-size: inherit; + line-height: inherit; + white-space: pre-wrap; + word-break: break-word; +} + +.ai-type-ahead-inline-ghost { + pointer-events: none; + user-select: none; + color: var(--ai-type-ahead-ghost-color, #8a8f98); + opacity: 1; + font-style: normal; + white-space: pre-wrap; + word-break: break-word; +} + +.ai-type-ahead-block [contenteditable='true'] .ai-type-ahead-inline-ghost { + color: var(--ai-type-ahead-ghost-color, #8a8f98) !important; + opacity: 0.72; +} + +/* Hide Gutenberg placeholder only while an empty block has active ghost text. */ +.ai-type-ahead-hide-placeholder [data-rich-text-placeholder] { + display: none !important; +} diff --git a/src/experiments/type-ahead/index.tsx b/src/experiments/type-ahead/index.tsx new file mode 100644 index 000000000..fbb17de5b --- /dev/null +++ b/src/experiments/type-ahead/index.tsx @@ -0,0 +1,59 @@ +/** + * Type-ahead inline ghost text experiment. + */ + +/** + * External dependencies + */ +import type { ComponentType } from 'react'; + +/** + * WordPress dependencies + */ +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import TypeAheadBlock from './components/TypeAheadBlock'; +import { ALLOWED_BLOCKS } from './constants'; +import type { TypeAheadSettings } from './types'; +import './index.scss'; + +/** + * Registers the editor block wrapper for type-ahead support. + * + * @param {TypeAheadSettings} settings Experiment settings. + */ +const bootstrap = ( settings: TypeAheadSettings ) => { + if ( ! settings.enabled ) { + return; + } + + const allowedBlocks = new Set( ALLOWED_BLOCKS ); + if ( settings.showHeadings ) { + allowedBlocks.add( 'core/heading' ); + } + + const withTypeAhead = createHigherOrderComponent( + ( BlockEdit: ComponentType< any > ) => { + return ( props: any ) => ( + + ); + }, + 'withAITypeAhead' + ); + + addFilter( 'editor.BlockEdit', 'ai/type-ahead', withTypeAhead ); +}; + +const settings = window.aiTypeAheadData; +if ( settings ) { + bootstrap( settings ); +} diff --git a/src/experiments/type-ahead/types.ts b/src/experiments/type-ahead/types.ts new file mode 100644 index 000000000..452ecc5cd --- /dev/null +++ b/src/experiments/type-ahead/types.ts @@ -0,0 +1,49 @@ +/** + * Type definitions for type-ahead. + */ + +export type CompletionMode = 'word' | 'sentence' | 'paragraph' | 'smart'; + +export type TypeAheadSettings = { + enabled: boolean; + completionMode: CompletionMode; + triggerDelay: number; + confidence: number; + showHeadings: boolean; + maxWords: number; +}; + +export type Suggestion = { + text: string; + confidence: number; +}; + +export type CaretData = { + offset: number; + rect: DOMRect | null; + precedingText: string; + ownerDocument: Document; +}; + +export type TypeAheadResponse = { + suggestion?: string; + confidence?: number; +}; + +export type TypeAheadAbilityInput = { + post_id: number; + block_content: string; + preceding_text: string; + following_text: string; + surrounding_context: string; + cursor_position: number; + mode: CompletionMode; + max_words: number; + manual_trigger: boolean; +}; + +declare global { + interface Window { + aiTypeAheadData?: TypeAheadSettings; + } +} diff --git a/src/experiments/type-ahead/utils/text.ts b/src/experiments/type-ahead/utils/text.ts new file mode 100644 index 000000000..57c399dff --- /dev/null +++ b/src/experiments/type-ahead/utils/text.ts @@ -0,0 +1,111 @@ +/** + * Utility functions for text. + */ + +/** + * Internal dependencies + */ +import { LEADING_WHITESPACE_REGEX, WHITESPACE_REGEX } from '../constants'; + +/** + * Converts HTML to plain text. + * + * @param {string} value The HTML to convert. + * @return {string} The plain text. + */ +export const htmlToPlainText = ( value?: string ): string => { + if ( ! value ) { + return ''; + } + + const temp = document.createElement( 'div' ); + temp.innerHTML = value; + + return ( temp.textContent || temp.innerText || '' ).replaceAll( + '\u00A0', + ' ' + ); +}; + +/** + * Determines if a context should trigger a type-ahead suggestion. + * + * @param {string} preceding The preceding text. + * @return {boolean} Whether the context should trigger a type-ahead suggestion. + */ +export const shouldTriggerFromContext = ( preceding: string ): boolean => { + const trimmed = preceding.trimEnd(); + + if ( ! trimmed ) { + return false; + } + + const lastChar = trimmed.slice( -1 ); + if ( [ '.', '?', '!', ':' ].includes( lastChar ) ) { + return true; + } + + const lower = trimmed.toLowerCase(); + return lower.endsWith( 'such as' ) || lower.endsWith( 'for example' ); +}; + +/** + * Splits a suggestion into a word or sentence. + * + * @param {string} suggestion The suggestion to split. + * @param {string} mode The mode to split the suggestion into. + * @return {Object} The split suggestion. + */ +export const splitSuggestion = ( + suggestion: string, + mode: 'word' | 'sentence' | 'all' +): { apply: string; remainder: string } => { + if ( mode === 'all' ) { + return { apply: suggestion, remainder: '' }; + } + + if ( mode === 'word' ) { + const match = suggestion.match( /^\s*\S+\s*/ ); + const chunk = match ? match[ 0 ] : suggestion; + + return { + apply: chunk, + remainder: suggestion.slice( chunk.length ), + }; + } + + const sentenceMatch = suggestion.match( /^(.*?[\.!?](?:\s|$))/ ); + const sentence = sentenceMatch ? sentenceMatch[ 0 ] : suggestion; + + return { + apply: sentence, + remainder: suggestion.slice( sentence.length ), + }; +}; + +/** + * Adds a leading space to a text if needed. + * + * @param {string} text The text to add a leading space to. + * @param {string} precedingText The preceding text. + * @return {string} The text with a leading space. + */ +export const addLeadingSpaceIfNeeded = ( + text: string, + precedingText: string +): string => { + if ( ! text || ! precedingText ) { + return text; + } + + const lastChar = precedingText.slice( -1 ); + if ( ! lastChar || WHITESPACE_REGEX.test( lastChar ) ) { + return text; + } + + if ( LEADING_WHITESPACE_REGEX.test( text ) ) { + return text; + } + + return ` ${ text }`; +}; diff --git a/src/utils/run-ability.ts b/src/utils/run-ability.ts index 85aa5922f..4a0dca90e 100644 --- a/src/utils/run-ability.ts +++ b/src/utils/run-ability.ts @@ -24,6 +24,7 @@ type Method = 'GET' | 'POST' | 'DELETE'; type RunAbilityOptions = { method?: Method; + signal?: AbortSignal; }; interface WindowWithAbilities extends Window { @@ -129,9 +130,10 @@ export async function runAbility< T = unknown >( const method: Method = options?.method ?? 'POST'; - const response = await apiFetch( - buildFetchOptions( ability, input, method ) - ); + const response = await apiFetch( { + ...buildFetchOptions( ability, input, method ), + ...( options?.signal ? { signal: options.signal } : {} ), + } ); return response as T; } diff --git a/tests/Integration/Includes/Abilities/Type_AheadTest.php b/tests/Integration/Includes/Abilities/Type_AheadTest.php new file mode 100644 index 000000000..c949a60a2 --- /dev/null +++ b/tests/Integration/Includes/Abilities/Type_AheadTest.php @@ -0,0 +1,392 @@ + 'Type-ahead Text', + 'description' => 'Ghost text suggestions while writing paragraphs in the block editor.', + ); + } + + /** + * {@inheritDoc} + * + * @since x.x.x + */ + public function register(): void { + // No-op for testing. + } +} + +/** + * Type_Ahead Ability test case. + * + * @since x.x.x + */ +class Type_AheadTest extends WP_UnitTestCase { + /** + * Ability instance. + * + * @since x.x.x + * + * @var Type_Ahead + */ + private $ability; + + /** + * Sets up the test case. + * + * @since x.x.x + */ + public function setUp(): void { + parent::setUp(); + + $experiment = new Test_Type_Ahead_Experiment(); + $this->ability = new Type_Ahead( + 'ai/type-ahead', + array( + 'label' => $experiment->get_label(), + 'description' => $experiment->get_description(), + ) + ); + } + + /** + * Tears down the test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + parent::tearDown(); + } + + /** + * Returns an accessible reflection method. + * + * @since x.x.x + * + * @param string $name Method name. + * @return \ReflectionMethod + */ + private function get_method( string $name ): \ReflectionMethod { + $reflection = new \ReflectionClass( $this->ability ); + $method = $reflection->getMethod( $name ); + $method->setAccessible( true ); + return $method; + } + + /** + * Tests input schema structure. + * + * @since x.x.x + */ + public function test_input_schema_returns_expected_structure() { + $schema = $this->get_method( 'input_schema' )->invoke( $this->ability ); + + $this->assertIsArray( $schema ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'block_content', $schema['properties'] ); + $this->assertArrayHasKey( 'preceding_text', $schema['properties'] ); + $this->assertArrayHasKey( 'following_text', $schema['properties'] ); + $this->assertArrayHasKey( 'surrounding_context', $schema['properties'] ); + $this->assertArrayHasKey( 'cursor_position', $schema['properties'] ); + $this->assertArrayHasKey( 'mode', $schema['properties'] ); + $this->assertArrayHasKey( 'max_words', $schema['properties'] ); + $this->assertArrayHasKey( 'manual_trigger', $schema['properties'] ); + $this->assertContains( 'block_content', $schema['required'] ); + } + + /** + * Tests output schema structure. + * + * @since x.x.x + */ + public function test_output_schema_returns_expected_structure() { + $schema = $this->get_method( 'output_schema' )->invoke( $this->ability ); + + $this->assertIsArray( $schema ); + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'suggestion', $schema['properties'] ); + $this->assertArrayHasKey( 'confidence', $schema['properties'] ); + $this->assertArrayHasKey( 'cursor_position', $schema['properties'] ); + } + + /** + * Tests meta schema. + * + * @since x.x.x + */ + public function test_meta_returns_expected_structure() { + $meta = $this->get_method( 'meta' )->invoke( $this->ability ); + + $this->assertIsArray( $meta ); + $this->assertArrayHasKey( 'show_in_rest', $meta ); + $this->assertTrue( $meta['show_in_rest'] ); + } + + /** + * Tests system instruction loading. + * + * @since x.x.x + */ + public function test_get_system_instruction_returns_string() { + $instruction = $this->ability->get_system_instruction(); + $this->assertIsString( $instruction ); + $this->assertNotEmpty( $instruction ); + } + + /** + * Tests private suggestion schema. + * + * @since x.x.x + */ + public function test_suggestion_schema_returns_expected_structure() { + $schema = $this->get_method( 'suggestion_schema' )->invoke( $this->ability ); + + $this->assertSame( 'object', $schema['type'] ); + $this->assertArrayHasKey( 'properties', $schema ); + $this->assertArrayHasKey( 'suggestion', $schema['properties'] ); + $this->assertArrayHasKey( 'confidence', $schema['properties'] ); + $this->assertContains( 'suggestion', $schema['required'] ); + $this->assertContains( 'confidence', $schema['required'] ); + } + + /** + * Tests prompt context composition. + * + * @since x.x.x + */ + public function test_prepare_prompt_context_returns_expected_payload() { + $context = $this->get_method( 'prepare_prompt_context' )->invoke( + $this->ability, + 'Block text', + 'Before', + 'After', + 'Neighbors', + 6, + 'smart', + 20, + true + ); + + $this->assertSame( 'smart', $context['mode'] ); + $this->assertSame( 20, $context['max_words'] ); + $this->assertSame( 6, $context['cursor_position'] ); + $this->assertSame( 'Block text', $context['block_content'] ); + $this->assertTrue( $context['manual_trigger'] ); + } + + /** + * Tests text truncation behavior. + * + * @since x.x.x + */ + public function test_truncate_text_respects_context_limit() { + $long_text = str_repeat( 'a', 5500 ); + $result = $this->get_method( 'truncate_text' )->invoke( $this->ability, $long_text ); + + $this->assertSame( 5000, mb_strlen( $result ) ); + $this->assertSame( mb_substr( $long_text, -5000 ), $result ); + } + + /** + * Tests cache key generation changes by mode. + * + * @since x.x.x + */ + public function test_build_cache_key_changes_when_mode_changes() { + $method = $this->get_method( 'build_cache_key' ); + + $key_a = $method->invoke( $this->ability, 'Text', 'Before', 'word', 20 ); + $key_b = $method->invoke( $this->ability, 'Text', 'Before', 'smart', 20 ); + + $this->assertIsString( $key_a ); + $this->assertIsString( $key_b ); + $this->assertNotSame( $key_a, $key_b ); + } + + /** + * Tests permission callback for edit_posts users. + * + * @since x.x.x + */ + public function test_permission_callback_with_edit_posts_capability() { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $this->get_method( 'permission_callback' )->invoke( $this->ability, array() ); + + $this->assertTrue( $result ); + } + + /** + * Tests permission callback for users without edit_posts. + * + * @since x.x.x + */ + public function test_permission_callback_without_edit_posts_capability() { + $user_id = $this->factory->user->create( array( 'role' => 'subscriber' ) ); + wp_set_current_user( $user_id ); + + $result = $this->get_method( 'permission_callback' )->invoke( $this->ability, array() ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'insufficient_capabilities', $result->get_error_code() ); + } + + /** + * Tests permission callback for valid post_id. + * + * @since x.x.x + */ + public function test_permission_callback_returns_true_for_editor_with_valid_post() { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $post_id = $this->factory->post->create( array( 'post_status' => 'publish' ) ); + $result = $this->get_method( 'permission_callback' )->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + $this->assertTrue( $result ); + } + + /** + * Tests permission callback for missing post_id. + * + * @since x.x.x + */ + public function test_permission_callback_returns_error_for_missing_post() { + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $result = $this->get_method( 'permission_callback' )->invoke( $this->ability, array( 'post_id' => 999999 ) ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'post_not_found', $result->get_error_code() ); + } + + /** + * Tests permission callback for non-REST post types. + * + * @since x.x.x + */ + public function test_permission_callback_returns_false_for_non_rest_post_type() { + register_post_type( + 'ai_tahead_private', + array( + 'public' => true, + 'show_in_rest' => false, + ) + ); + + $user_id = $this->factory->user->create( array( 'role' => 'editor' ) ); + wp_set_current_user( $user_id ); + + $post_id = $this->factory->post->create( + array( + 'post_type' => 'ai_tahead_private', + 'post_status' => 'publish', + ) + ); + + $result = $this->get_method( 'permission_callback' )->invoke( $this->ability, array( 'post_id' => $post_id ) ); + + unregister_post_type( 'ai_tahead_private' ); + + $this->assertFalse( $result ); + } + + /** + * Tests execute_callback() returns cached value when present. + * + * @since x.x.x + */ + public function test_execute_callback_returns_cached_result() { + $key_method = $this->get_method( 'build_cache_key' ); + $execute_method = $this->get_method( 'execute_callback' ); + + $cache_key = $key_method->invoke( $this->ability, 'Hello world', '', 'smart', 20 ); + $cached = array( + 'suggestion' => ' next', + 'confidence' => 0.9, + 'cursor_position' => 11, + ); + + wp_cache_set( $cache_key, $cached, 'ai-type-ahead', 45 ); + + $result = $execute_method->invoke( + $this->ability, + array( + 'block_content' => 'Hello world', + ) + ); + + wp_cache_delete( $cache_key, 'ai-type-ahead' ); + + $this->assertSame( $cached, $result ); + } + + /** + * Tests empty block content can still execute through cached path. + * + * @since x.x.x + */ + public function test_execute_callback_allows_empty_block_content() { + $key_method = $this->get_method( 'build_cache_key' ); + $execute_method = $this->get_method( 'execute_callback' ); + + $cache_key = $key_method->invoke( $this->ability, '', '', 'smart', 20 ); + $cached = array( + 'suggestion' => 'Start here', + 'confidence' => 0.7, + 'cursor_position' => 0, + ); + + wp_cache_set( $cache_key, $cached, 'ai-type-ahead', 45 ); + + $result = $execute_method->invoke( + $this->ability, + array( + 'block_content' => '', + ) + ); + + wp_cache_delete( $cache_key, 'ai-type-ahead' ); + + $this->assertIsArray( $result ); + $this->assertSame( $cached, $result ); + } +} diff --git a/tests/Integration/Includes/Experiments/ExperimentsTest.php b/tests/Integration/Includes/Experiments/ExperimentsTest.php index 44f6d27e0..87228674a 100644 --- a/tests/Integration/Includes/Experiments/ExperimentsTest.php +++ b/tests/Integration/Includes/Experiments/ExperimentsTest.php @@ -10,6 +10,7 @@ use WP_UnitTestCase; use WordPress\AI\Experiments\Abilities_Explorer\Abilities_Explorer; use WordPress\AI\Experiments\Experiments; +use WordPress\AI\Experiments\Type_Ahead\Type_Ahead; /** * Tests for the Experiments class. @@ -35,5 +36,6 @@ public function test_init_hooks_filter() { // Test a random experiment to ensure it's registered as a default experiment. $this->assertContains( Abilities_Explorer::class, $results, 'Abilities_Explorer should be registered as a default experiment.' ); + $this->assertContains( Type_Ahead::class, $results, 'Type_Ahead should be registered as a default experiment.' ); } } diff --git a/tests/Integration/Includes/Experiments/Type_Ahead/Type_AheadTest.php b/tests/Integration/Includes/Experiments/Type_Ahead/Type_AheadTest.php new file mode 100644 index 000000000..4b84e9e1b --- /dev/null +++ b/tests/Integration/Includes/Experiments/Type_Ahead/Type_AheadTest.php @@ -0,0 +1,174 @@ + 'test-api-key' ) ); + add_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + + update_option( 'wpai_features_enabled', true ); + update_option( 'wpai_feature_type-ahead_enabled', true ); + + $registry = new Registry(); + $loader = new Loader( $registry ); + $loader->init(); + + $experiment = $registry->get_feature( 'type-ahead' ); + $this->assertInstanceOf( Type_Ahead::class, $experiment ); + } + + /** + * Tear down test case. + * + * @since x.x.x + */ + public function tearDown(): void { + wp_set_current_user( 0 ); + delete_option( 'wpai_features_enabled' ); + delete_option( 'wpai_feature_type-ahead_enabled' ); + delete_option( 'wpai_feature_type-ahead_field_mode' ); + delete_option( 'wpai_feature_type-ahead_field_delay' ); + delete_option( 'wpai_feature_type-ahead_field_confidence' ); + delete_option( 'wpai_feature_type-ahead_field_max_words' ); + delete_option( 'wpai_feature_type-ahead_field_headings' ); + delete_option( 'wp_ai_client_provider_credentials' ); + remove_filter( 'wpai_pre_has_valid_credentials_check', '__return_true' ); + parent::tearDown(); + } + + /** + * Tests experiment metadata and registration. + * + * @since x.x.x + */ + public function test_experiment_registration() { + $experiment = new Type_Ahead(); + + $this->assertEquals( 'type-ahead', $experiment->get_id() ); + $this->assertEquals( 'Type-ahead Text', $experiment->get_label() ); + $this->assertEquals( Experiment_Category::EDITOR, $experiment->get_category() ); + $this->assertTrue( $experiment->is_enabled() ); + } + + /** + * Tests experiment can be disabled via filter. + * + * @since x.x.x + */ + public function test_experiment_can_be_disabled_via_filter() { + add_filter( 'wpai_feature_type-ahead_enabled', '__return_false' ); + + $experiment = new Type_Ahead(); + $this->assertFalse( $experiment->is_enabled() ); + + remove_all_filters( 'wpai_feature_type-ahead_enabled' ); + } + + /** + * Tests register() hooks expected actions. + * + * @since x.x.x + */ + public function test_register_hooks_actions() { + $experiment = new Type_Ahead(); + $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_assets', array( $experiment, 'enqueue_assets' ) ), + 'enqueue_assets should be hooked to enqueue_block_assets' + ); + } + + /** + * Tests register_settings() registers expected settings with REST exposure. + * + * @since x.x.x + */ + public function test_register_settings_has_show_in_rest() { + $experiment = new Type_Ahead(); + $experiment->register_settings(); + + $registered = get_registered_settings(); + $keys = array( + 'wpai_feature_type-ahead_field_mode', + 'wpai_feature_type-ahead_field_delay', + 'wpai_feature_type-ahead_field_confidence', + 'wpai_feature_type-ahead_field_max_words', + 'wpai_feature_type-ahead_field_headings', + ); + + foreach ( $keys as $key ) { + $this->assertArrayHasKey( $key, $registered, sprintf( '%s should be registered', $key ) ); + $this->assertNotEmpty( $registered[ $key ]['show_in_rest'], sprintf( '%s should have show_in_rest', $key ) ); + } + } + + /** + * Tests setting sanitizers. + * + * @since x.x.x + */ + public function test_sanitizers() { + $experiment = new Type_Ahead(); + + $this->assertSame( 'word', $experiment->sanitize_mode( 'word' ) ); + $this->assertSame( 'smart', $experiment->sanitize_mode( 'invalid' ) ); + + $this->assertSame( 200, $experiment->sanitize_delay( 100 ) ); + $this->assertSame( 2000, $experiment->sanitize_delay( 3000 ) ); + $this->assertSame( 450, $experiment->sanitize_delay( 450 ) ); + + $this->assertSame( 0, $experiment->sanitize_confidence( -10 ) ); + $this->assertSame( 100, $experiment->sanitize_confidence( 110 ) ); + $this->assertSame( 75, $experiment->sanitize_confidence( 75 ) ); + + $this->assertSame( 1, $experiment->sanitize_max_words( 0 ) ); + $this->assertSame( 50, $experiment->sanitize_max_words( 100 ) ); + $this->assertSame( 12, $experiment->sanitize_max_words( 12 ) ); + } + + /** + * Tests get_settings_fields() structure. + * + * @since x.x.x + */ + public function test_get_settings_fields_returns_expected_fields() { + $experiment = new Type_Ahead(); + $fields = $experiment->get_settings_fields(); + + $this->assertCount( 5, $fields ); + $this->assertSame( 'mode', $fields[0]['id'] ); + $this->assertSame( 'delay', $fields[1]['id'] ); + $this->assertSame( 'confidence', $fields[2]['id'] ); + $this->assertSame( 'max_words', $fields[3]['id'] ); + $this->assertSame( 'headings', $fields[4]['id'] ); + } +} diff --git a/tests/e2e-request-mocking/e2e-request-mocking.php b/tests/e2e-request-mocking/e2e-request-mocking.php index d1b9e7134..ac1e19eac 100644 --- a/tests/e2e-request-mocking/e2e-request-mocking.php +++ b/tests/e2e-request-mocking/e2e-request-mocking.php @@ -74,6 +74,9 @@ function ai_e2e_test_request_mocking( $preempt, $parsed_args, $url ) { } elseif ( is_string( $body ) && str_contains( $body, 'content taxonomy assistant' ) ) { // Route content-classification requests to their own fixture. $response = file_get_contents( __DIR__ . '/responses/OpenAI/content-classification-responses.json' ); + } elseif ( is_string( $body ) && str_contains( $body, 'inline ghost text suggestions' ) ) { + // Route type-ahead text requests to their own fixture. + $response = file_get_contents( __DIR__ . '/responses/OpenAI/type-ahead-responses.json' ); } else { $response = file_get_contents( __DIR__ . '/responses/OpenAI/responses.json' ); } diff --git a/tests/e2e-request-mocking/responses/OpenAI/type-ahead-responses.json b/tests/e2e-request-mocking/responses/OpenAI/type-ahead-responses.json new file mode 100644 index 000000000..48f38c89c --- /dev/null +++ b/tests/e2e-request-mocking/responses/OpenAI/type-ahead-responses.json @@ -0,0 +1,71 @@ +{ + "id": "resp_cc_e2e_test_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_cc_e2e_test_mock_001", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "{\"suggestion\":\"This is a test suggestion.\",\"confidence\":0.95,\"cursor_position\":10}" + } + ], + "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/type-ahead.spec.js b/tests/e2e/specs/experiments/type-ahead.spec.js new file mode 100644 index 000000000..c73fa3d3d --- /dev/null +++ b/tests/e2e/specs/experiments/type-ahead.spec.js @@ -0,0 +1,171 @@ +/** + * WordPress dependencies + */ +const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Internal dependencies + */ +const { + disableExperiment, + disableExperiments, + enableExperiment, + enableExperiments, +} = require( '../../utils/helpers' ); + +test.describe( 'Type-ahead Text Experiment', () => { + test( 'Can enable the type-ahead text experiment', async ( { + admin, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Type-ahead Text Experiment. + await enableExperiment( admin, page, 'Type-ahead Text' ); + } ); + + test( 'Can use the Type-ahead Text Experiment', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Enable the Type-ahead Text Experiment. + await enableExperiment( admin, page, 'Type-ahead Text' ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Type-ahead Text Experiment', + content: + 'This is some test content for the Type-ahead Text Experiment.', + } ); + + // Add a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'This paragraph needs more text.', + }, + } ); + + // Click into the block. + await editor.canvas.locator( '.wp-block-paragraph' ).click(); + + // Ensure the type-ahead text is visible and has the correct text. + await expect( + editor.canvas.locator( '.ai-type-ahead-overlay' ) + ).toBeVisible(); + await expect( + editor.canvas.locator( '.ai-type-ahead-overlay' ) + ).toHaveText( 'This is a test suggestion.' ); + + // Accept the type-ahead text. + await page.keyboard.press( 'Tab' ); + + // Ensure the type-ahead text is removed. + await expect( + editor.canvas.locator( '.ai-type-ahead-overlay' ) + ).toBeHidden(); + await expect( + editor.canvas.locator( '.ai-type-ahead-inline-ghost' ) + ).toBeHidden(); + + // Ensure the block content is updated. + await expect( + editor.canvas.locator( '.wp-block-paragraph' ) + ).toHaveText( + 'This paragraph needs more text. This is a test suggestion.' + ); + + // Save the post. + await editor.saveDraft(); + } ); + + test( 'Ensure the Type-ahead Text Experiment UI is not visible when Experiments are globally disabled', async ( { + admin, + editor, + page, + } ) => { + // Enable the Type-ahead Text Experiment. + await enableExperiment( admin, page, 'Type-ahead Text' ); + + // Globally turn off Experiments. + await disableExperiments( admin, page ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Type-ahead Text Experiment Globally Disabled', + content: + 'This is some test content for the Type-ahead Text Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Add a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'This paragraph needs more text.', + }, + } ); + + // Click into the block. + await editor.canvas.locator( '.wp-block-paragraph' ).click(); + + // Ensure the type-ahead text is not visible. + await expect( + editor.canvas.locator( '.ai-type-ahead-overlay' ) + ).toBeHidden(); + await expect( + editor.canvas.locator( '.ai-type-ahead-inline-ghost' ) + ).toBeHidden(); + } ); + + test( 'Ensure the Type-ahead Text Experiment UI is not visible when the experiment is disabled', async ( { + admin, + editor, + page, + } ) => { + // Globally turn on Experiments. + await enableExperiments( admin, page ); + + // Disable the Type-ahead Text Experiment. + await disableExperiment( admin, page, 'Type-ahead Text' ); + + // Create a new post. + await admin.createNewPost( { + postType: 'post', + title: 'Test Type-ahead Text Experiment Disabled', + content: + 'This is some test content for the Type-ahead Text Experiment.', + } ); + + // Save the post. + await editor.saveDraft(); + + // Add a block. + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'This paragraph needs more text.', + }, + } ); + + // Click into the block. + await editor.canvas.locator( '.wp-block-paragraph' ).click(); + + // Ensure the type-ahead text is not visible. + await expect( + editor.canvas.locator( '.ai-type-ahead-overlay' ) + ).toBeHidden(); + await expect( + editor.canvas.locator( '.ai-type-ahead-inline-ghost' ) + ).toBeHidden(); + } ); +} ); diff --git a/tests/e2e/utils/helpers.ts b/tests/e2e/utils/helpers.ts index 3b1a2329b..bae77231c 100644 --- a/tests/e2e/utils/helpers.ts +++ b/tests/e2e/utils/helpers.ts @@ -407,7 +407,7 @@ export const getExperimentTogglesInGroup = async ( .filter( { has: page.getByText( groupName, { exact: true } ) } ); // Get all checkboxes in that section (experiment toggles are checkboxes, buttons are for bulk actions). - const allToggles = section.getByRole( 'checkbox' ); + const allToggles = section.locator( '.components-form-toggle__input' ); const count = await allToggles.count(); const experimentToggles: Locator[] = []; diff --git a/webpack.config.js b/webpack.config.js index 408a57b67..1a3f8715e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -78,6 +78,11 @@ module.exports = { 'src/experiments/title-generation', 'index.tsx' ), + 'experiments/type-ahead': path.resolve( + process.cwd(), + 'src/experiments/type-ahead', + 'index.tsx' + ), 'experiments/alt-text-generation': path.resolve( process.cwd(), 'src/experiments/alt-text-generation',