diff --git a/docs/experiments/type-ahead.md b/docs/experiments/type-ahead.md new file mode 100644 index 00000000..ade2d2c0 --- /dev/null +++ b/docs/experiments/type-ahead.md @@ -0,0 +1,31 @@ +# Type Ahead + +## Summary +Adds inline "ghost text" completions to the block editor. When the experiment and global toggle are enabled, typing inside supported blocks (paragraphs by default, optional headings) displays translucent suggestions that can be accepted via keyboard shortcuts. + +## Key Hooks & Entry Points +- `WordPress\AI\Experiments\Type_Ahead\Type_Ahead::register()` hooks `wp_abilities_api_init` to register the AI ability and `enqueue_block_editor_assets` to load assets only when the block editor runs. +- `Type_Ahead::register_settings()` stores experiment-specific options under the Experiments settings screen (`ai_experiment_type_ahead_*`). +- `src/experiments/type-ahead/index.tsx` registers an `editor.BlockEdit` filter (HOC) that adds the overlay for each supported block and wires keyboard handlers. +- Ability implementation lives at `includes/Abilities/Type_Ahead/Type_Ahead.php` and is registered as `ai/type-ahead`. + +## Assets & Data Flow +1. PHP enqueues the `experiments/type-ahead` script and `experiments/style-type-ahead` stylesheet, then localizes `aiTypeAheadData` with flags like `completionMode`, `triggerDelay`, and `abilityName`. +2. The React entry point polls for the localized data, then wraps block edits to render the overlay component and keyboard hints, and calls `executeAbility( 'ai/type-ahead', ... )` when it is time to request a suggestion. +3. The ability sanitizes input (post ID, caret position, snippets of block content) and composes a structured payload via `prepare_prompt_context()`. +4. `AI_Client::prompt_with_wp_error()` is invoked with the system instruction in `includes/Abilities/Type_Ahead/system-instruction.php`, returning JSON `{ suggestion, confidence }` that the ability validates before passing back to JS. +5. The UI caches the last suggestion per block/caret, displays it via a portal near the caret, and dispatches synthetic `input` events when the user accepts a suggestion so Gutenberg updates its state. + +## Testing +1. Enable Experiments globally and toggle **Type-ahead Text** under `Settings > AI Experiments`. (Optional: adjust mode/delay/confidence/headings.) +2. Open the block editor, insert a Paragraph block, and start typing sentences until the caret is at the end of a sentence. After the configured delay a faint suggestion should appear. +3. Press `Tab` to accept the full suggestion, `Cmd/Ctrl + Right Arrow` for a word, or `Cmd/Ctrl + Shift + Right Arrow` for a sentence. The accepted text should become real content and the remainder should stay ghosted if available. +4. Press `Esc` to dismiss a suggestion. Use `Cmd/Ctrl + Space` to force a manual fetch even when the caret context would not auto-trigger. +5. Toggle the "Enable in headings" checkbox in settings, then verify `core/heading` blocks now receive suggestions. + +## Notes +- Suggestions are cached briefly (45 seconds) per block/caret combination to reduce provider calls. +- Permissions require `edit_post` when a post ID is provided, otherwise `edit_posts`. +- The ability trims contexts to 5000 characters and normalizes HTML content before sending it to the model. +- Settings are stored via the standard options API; sanitizers enforce safe ranges (delay 200-2000 ms, confidence 0-100, etc.). +- The client aborts long-running REST calls after roughly 15 seconds to avoid default 20s browser/`apiFetch` timeouts while still giving the server time; writers can press Cmd/Ctrl + Space to manually try again. diff --git a/includes/Abilities/Type_Ahead/Type_Ahead.php b/includes/Abilities/Type_Ahead/Type_Ahead.php new file mode 100644 index 00000000..efaf9a00 --- /dev/null +++ b/includes/Abilities/Type_Ahead/Type_Ahead.php @@ -0,0 +1,457 @@ + 'object', + 'properties' => array( + 'post_id' => array( + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Post ID used to gather additional context.', 'ai' ), + ), + 'block_id' => array( + 'type' => 'string', + 'sanitize_callback' => 'sanitize_key', + 'description' => esc_html__( 'Unique identifier of the block requesting the suggestion.', '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} + */ + 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} + * + * @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_id' => '', + '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'] ); + + $this->log_debug( + 'Received Type Ahead request', + array( + 'post_id' => $args['post_id'], + 'block_id' => $args['block_id'], + 'mode' => $mode, + 'max_words' => $max_words, + 'cursor_position' => $cursor_position, + 'manual_trigger' => (bool) $args['manual_trigger'], + 'block_length' => mb_strlen( $block_content ), + 'preceding_len' => mb_strlen( $preceding_text ), + 'following_len' => mb_strlen( $following_text ), + 'surrounding_len' => mb_strlen( $surrounding ), + ) + ); + + if ( '' === $block_content ) { + $this->log_debug( 'Rejected request with empty block content', array( 'block_id' => $args['block_id'] ) ); + return new WP_Error( 'ai_type_ahead_missing_block', esc_html__( 'Block content is required for type-ahead suggestions.', 'ai' ) ); + } + + 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 ) ) { + $this->log_debug( + 'Cache hit for Type Ahead request', + array( + 'block_id' => $args['block_id'], + 'cursor_position' => $cursor_position, + 'mode' => $mode, + 'max_words' => $max_words, + ) + ); + return $cached; + } + + $context = $this->prepare_prompt_context( $args['post_id'], $block_content, $preceding_text, $following_text, $surrounding, $cursor_position, $mode, $max_words, (bool) $args['manual_trigger'] ); + + $this->log_debug( + 'Dispatching Type Ahead prompt', + array( + 'block_id' => $args['block_id'], + 'cursor_position' => $cursor_position, + 'manual_trigger' => (bool) $args['manual_trigger'], + ) + ); + + $start_time = microtime( true ); + $result = $this->generate_suggestion( $context ); + $duration = ( microtime( true ) - $start_time ) * 1000; + + if ( is_wp_error( $result ) ) { + $this->log_debug( + 'Type Ahead provider returned WP_Error', + array( + 'code' => $result->get_error_code(), + 'message' => $result->get_error_message(), + 'duration_ms' => (int) round( $duration ), + ) + ); + return $result; + } + + $result['cursor_position'] = $cursor_position; + + $this->log_debug( + 'Type Ahead suggestion ready', + array( + 'block_id' => $args['block_id'], + 'cursor_position' => $cursor_position, + 'confidence' => $result['confidence'], + 'preview' => mb_substr( $result['suggestion'], 0, 80 ), + 'duration_ms' => (int) round( $duration ), + ) + ); + + wp_cache_set( $cache_key, $result, self::CACHE_GROUP, self::CACHE_TTL ); + + return $result; + } + + /** + * {@inheritDoc} + */ + 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 ) { + $this->log_debug( 'Permission denied: post not found', array( 'post_id' => $post_id ) ); + 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 ) ) { + $this->log_debug( 'Permission denied: cannot edit post', array( 'post_id' => $post_id ) ); + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to request type-ahead suggestions for this post.', 'ai' ) + ); + } + } elseif ( ! current_user_can( 'edit_posts' ) ) { + $this->log_debug( 'Permission denied: cannot edit posts capability missing', array( 'user_id' => get_current_user_id() ) ); + return new WP_Error( + 'insufficient_capabilities', + esc_html__( 'You do not have permission to request type-ahead suggestions.', 'ai' ) + ); + } + + return true; + } + + /** + * {@inheritDoc} + */ + protected function meta(): array { + return array( + 'show_in_rest' => true, + 'mcp' => array( + 'public' => true, + 'type' => 'tool', + 'category' => 'editor', + ), + ); + } + + /** + * 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 ) { + $this->log_debug( + 'Calling AI client for Type Ahead', + array( + 'mode' => $context['mode'], + 'max_words' => $context['max_words'], + 'cursor' => $context['cursor_position'], + 'manual' => (bool) $context['manual_trigger'], + 'block_length' => mb_strlen( (string) $context['block_content'] ), + ) + ); + + $response = AI_Client::prompt_with_wp_error( wp_json_encode( $context ) ) + ->using_system_instruction( $this->get_system_instruction() ) + ->using_candidate_count( 1 ) + ->using_model_preference( + array( 'openai', 'gpt-5.1-nano' ), + array( 'anthropic', 'claude-haiku-4-5' ), + array( 'google', 'gemini-2.5-flash' ), + array( 'openai', 'gpt-4o-mini' ) + ) + ->generate_texts(); + + if ( is_wp_error( $response ) ) { + $this->log_debug( + 'AI client returned WP_Error', + array( + 'code' => $response->get_error_code(), + 'message' => $response->get_error_message(), + ) + ); + return $response; + } + + $text = $response[0] ?? ''; + + if ( ! is_string( $text ) || '' === trim( $text ) ) { + $this->log_debug( 'AI client returned empty response text' ); + return new WP_Error( 'ai_type_ahead_empty', esc_html__( 'The AI provider returned an empty suggestion.', 'ai' ) ); + } + + $data = $this->decode_suggestion_payload( $text ); + + if ( ! is_array( $data ) || empty( $data['suggestion'] ) ) { + $this->log_debug( 'AI response failed JSON decode', array( 'raw' => mb_substr( $text, 0, 160 ) ) ); + return new WP_Error( 'ai_type_ahead_invalid', esc_html__( 'Unable to parse the type-ahead suggestion response.', 'ai' ) ); + } + + $suggestion = sanitize_textarea_field( $data['suggestion'] ); + + if ( '' === $suggestion ) { + $this->log_debug( 'Suggestion blank after sanitization' ); + return new WP_Error( 'ai_type_ahead_blank', esc_html__( 'The suggestion returned was blank after sanitization.', 'ai' ) ); + } + + $confidence = isset( $data['confidence'] ) ? min( 1, max( 0, (float) $data['confidence'] ) ) : 0.0; + + return array( + 'suggestion' => $suggestion, + 'confidence' => $confidence, + ); + } + + /** + * Attempts to decode a JSON payload that may be wrapped in markdown fences or extra prose. + */ + private function decode_suggestion_payload( string $raw ): ?array { + $clean = trim( $raw ); + + if ( str_starts_with( $clean, '```' ) ) { + $clean = preg_replace( '/^```[a-zA-Z0-9_-]*\s*/', '', $clean ) ?? $clean; + if ( str_contains( $clean, '```' ) ) { + $clean = substr( $clean, 0, strpos( $clean, '```' ) ); + } + $clean = trim( $clean ); + } + + $decoded = json_decode( $clean, true ); + if ( is_array( $decoded ) ) { + return $decoded; + } + + if ( preg_match( '/\{.*\}/s', $clean, $matches ) === 1 ) { + $decoded = json_decode( $matches[0], true ); + if ( is_array( $decoded ) ) { + return $decoded; + } + } + + return null; + } + + /** + * Prepares the structured context payload for the prompt. + * + * @param int|null $post_id Optional post ID. + * @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( ?int $post_id, string $block_content, string $preceding_text, string $following_text, string $surrounding_context, int $cursor_position, string $mode, int $max_words, bool $manual_trigger ): array { + $post_context = array(); + + if ( $post_id ) { + $post_context = get_post_context( $post_id ); + } + + 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, + 'post_context' => $post_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 00000000..a60faf95 --- /dev/null +++ b/includes/Abilities/Type_Ahead/system-instruction.php @@ -0,0 +1,26 @@ + 'smart', + 'delay' => 500, + 'confidence' => 70, + 'headings' => false, + 'max_words' => 20, + ); + + /** + * {@inheritDoc} + */ + protected function load_experiment_metadata(): array { + return array( + 'id' => 'type-ahead', + 'label' => esc_html__( 'Type-ahead Text', 'ai' ), + 'description' => esc_html__( 'Ghost text suggestions while writing paragraphs in the block editor.', 'ai' ), + ); + } + + /** + * {@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' ) ); + } + + /** + * Registers the type-ahead ability. + */ + 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 editor assets. + * + * Assets are always enqueued so the JavaScript can provide appropriate + * feedback when the experiment is disabled. The enabled state is passed + * to the script which handles the conditional activation. + */ + public function enqueue_assets(): void { + Asset_Loader::enqueue_script( 'type_ahead', 'experiments/type-ahead' ); + Asset_Loader::enqueue_style( 'type_ahead', 'experiments/style-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, + 'showHeadings' => (bool) $settings['headings'], + 'maxWords' => (int) $settings['max_words'], + 'abilityName' => 'ai/' . $this->get_id(), + ) + ); + } + + /** + * {@inheritDoc} + */ + public function register_settings(): void { + register_setting( + Settings_Registration::OPTION_GROUP, + self::OPTION_MODE, + array( + 'type' => 'string', + 'default' => self::DEFAULTS['mode'], + 'sanitize_callback' => array( $this, 'sanitize_mode' ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + self::OPTION_DELAY, + array( + 'type' => 'integer', + 'default' => self::DEFAULTS['delay'], + 'sanitize_callback' => array( $this, 'sanitize_delay' ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + self::OPTION_CONFIDENCE, + array( + 'type' => 'integer', + 'default' => self::DEFAULTS['confidence'], + 'sanitize_callback' => array( $this, 'sanitize_confidence' ), + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + self::OPTION_HEADINGS, + array( + 'type' => 'boolean', + 'default' => self::DEFAULTS['headings'], + 'sanitize_callback' => 'rest_sanitize_boolean', + ) + ); + + register_setting( + Settings_Registration::OPTION_GROUP, + self::OPTION_MAX_WORDS, + array( + 'type' => 'integer', + 'default' => self::DEFAULTS['max_words'], + 'sanitize_callback' => array( $this, 'sanitize_max_words' ), + ) + ); + } + + /** + * Renders settings controls on the Experiments screen. + */ + public function render_settings_fields(): void { + $settings = $this->get_settings(); + ?> +
+ + + + + + + + + + + + + +
+ + */ + private function get_settings(): array { + return array( + 'mode' => get_option( self::OPTION_MODE, self::DEFAULTS['mode'] ), + 'delay' => (int) get_option( self::OPTION_DELAY, self::DEFAULTS['delay'] ), + 'confidence' => (int) get_option( self::OPTION_CONFIDENCE, self::DEFAULTS['confidence'] ), + 'headings' => (bool) get_option( self::OPTION_HEADINGS, self::DEFAULTS['headings'] ), + 'max_words' => (int) get_option( self::OPTION_MAX_WORDS, self::DEFAULTS['max_words'] ), + ); + } + + /** + * Sanitizes the completion mode. + */ + public function sanitize_mode( $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. + */ + public function sanitize_delay( $value ): int { + $value = (int) $value; + + return max( 200, min( 2000, $value ) ); + } + + /** + * Sanitizes the confidence field. + */ + public function sanitize_confidence( $value ): int { + $value = (int) $value; + + return max( 0, min( 100, $value ) ); + } + + /** + * Sanitizes the max words field. + */ + public function sanitize_max_words( $value ): int { + $value = (int) $value; + + return max( 1, min( 50, $value ) ); + } + + /** + * {@inheritDoc} + */ + public function has_settings(): bool { + return true; + } + + /** + * {@inheritDoc} + */ + public function get_entry_points(): array { + return array( + array( + 'label' => esc_html__( 'Try', 'ai' ), + 'url' => admin_url( 'post-new.php' ), + 'type' => 'try', + ), + ); + } +} diff --git a/src/experiments/type-ahead/index.tsx b/src/experiments/type-ahead/index.tsx new file mode 100644 index 00000000..eba48b6b --- /dev/null +++ b/src/experiments/type-ahead/index.tsx @@ -0,0 +1,842 @@ +/** + * Type-ahead inline ghost text experiment. + */ + +/** + * Internal dependencies + */ +import './style.scss'; + +/** + * External dependencies + */ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { createHigherOrderComponent } from '@wordpress/compose'; +import { addFilter } from '@wordpress/hooks'; +import { useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; + +const ALLOWED_BLOCKS = [ 'core/paragraph' ]; +const GHOST_COLOR = 'var(--ai-type-ahead-ghost-color, #8a8f98)'; +const REQUEST_TIMEOUT_MS = 15000; // abort long-running completions to avoid apiFetch 20s timeout while giving server time. +const WHITESPACE_REGEX = /\s/; +const LEADING_WHITESPACE_REGEX = /^\s/; + +type CompletionMode = 'word' | 'sentence' | 'paragraph' | 'smart'; + +type TypeAheadSettings = { + enabled: boolean; + completionMode: CompletionMode; + triggerDelay: number; + confidence: number; // 0-1 + showHeadings: boolean; + maxWords: number; + abilityName: string; +}; + +type Suggestion = { + text: string; + confidence: number; +}; + +type CaretData = { + offset: number; + rect: DOMRect | null; + precedingText: string; + ownerDocument: Document; +}; + +declare global { + interface Window { + aiTypeAheadData?: TypeAheadSettings; + } +} + +/** + * Utility: convert RichText HTML to plain text for prompting. + */ +const htmlToPlainText = ( value?: string ): string => { + if ( ! value ) { + return ''; + } + const temp = document.createElement( 'div' ); + temp.innerHTML = value; + return ( temp.textContent || temp.innerText || '' ).replaceAll( + '\u00A0', + ' ' + ); +}; + +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' ); +}; + +const splitSuggestion = ( + suggestion: string, + mode: 'word' | 'sentence' | 'all' +) => { + 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 ) }; +}; + +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 }`; +}; + +const useBlockDom = ( clientId: string ) => { + const [ state, setState ] = useState< { + block: HTMLElement | null; + editable: HTMLElement | null; + } >( { + block: null, + editable: null, + } ); + + useEffect( () => { + let cancelled = false; + + 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 => { + // Check if the block element itself is editable (common for paragraph blocks) + if ( + blockEl.getAttribute( 'contenteditable' ) === 'true' || + blockEl.hasAttribute( 'data-rich-text-editable' ) + ) { + return blockEl; + } + + // Otherwise search for editable elements within the block + 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 } ); + } + }; + + lookup(); + const interval = window.setInterval( lookup, 750 ); + + return () => { + cancelled = true; + window.clearInterval( interval ); + }; + }, [ clientId ] ); + + return state; +}; + +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 sel = doc.getSelection(); + if ( ! sel || sel.rangeCount === 0 ) { + setCaret( null ); + return; + } + const range = sel.getRangeAt( 0 ); + if ( ! editable.contains( range.startContainer ) ) { + setCaret( null ); + return; + } + + const markerRange = range.cloneRange(); + const rects = markerRange.getClientRects(); + const rect = rects.length + ? rects[ 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; +}; + +const TypeAheadOverlay: React.FC< { + ownerDocument: Document | null; + rect: DOMRect | null; + container: HTMLElement | null; + text: string | null; +} > = ( { ownerDocument, rect, container, text } ) => { + const [ style, setStyle ] = useState< React.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', + pointerEvents: 'none', + display: 'block', + color: GHOST_COLOR, + opacity: 1, + fontStyle: 'normal', + fontSize: 'inherit', + lineHeight: 'inherit', + whiteSpace: 'pre-wrap', + wordBreak: 'break-word', + zIndex: 1, + top: rect.top + scrollY, + left: containerLeft + scrollX, + width: containerRect?.width, + textIndent: indent ? `${ indent }px` : undefined, + } ); + }, [ body, rect, win, container ] ); + + if ( ! body || ! rect || ! text || ! style ) { + return null; + } + + return createPortal( + , + body + ); +}; + +const TypeAheadBlock: React.FC< { + BlockEdit: React.ComponentType< any >; + blockProps: any; + settings: TypeAheadSettings; + allowedBlocks: Set< string >; +} > = ( { BlockEdit, blockProps, settings, allowedBlocks } ) => { + const { clientId, attributes, name } = blockProps; + const { block, editable } = useBlockDom( clientId ); + const caret = useCaretData( editable ); + const [ suggestion, setSuggestion ] = useState< Suggestion | null >( null ); + const [ requestNonce, setRequestNonce ] = useState( 0 ); + const requestRef = useRef( 0 ); + + const { selectedClientId, siblingContext, postId } = useSelect( + ( selectCb ) => { + const blockEditor = selectCb( blockEditorStore ); + const editor = selectCb( editorStore ); + const selected = blockEditor.getSelectedBlockClientId(); + const rootClientId = blockEditor.getBlockRootClientId( clientId ); + const order = rootClientId + ? blockEditor.getBlockOrder( rootClientId ) + : []; + 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, next?.content ] + .filter( Boolean ) + .map( ( value ) => htmlToPlainText( value as string ) ) + .join( '\n\n' ) + .trim(); + + return { + selectedClientId: selected, + siblingContext: neighborText, + postId: editor.getCurrentPostId(), + }; + }, + [ clientId ] + ); + + const plainContent = useMemo( + () => htmlToPlainText( attributes?.content || '' ), + [ 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 && + plainContent.trim().length > 0; + + const abilityName = settings.abilityName || 'ai/type-ahead'; + const abortControllerRef = useRef< AbortController | null >( null ); + const debounceTimerRef = useRef< number | null >( null ); + const requestTimeoutRef = useRef< number | null >( null ); + + // Stable reference to current values for use in callbacks + const stateRef = useRef( { + caret, + plainContent, + followingText, + siblingContext, + postId, + clientId, + completionMode: settings.completionMode, + confidence: settings.confidence, + maxWords: settings.maxWords, + } ); + + // Update the ref on each render + useEffect( () => { + stateRef.current = { + caret, + plainContent, + followingText, + siblingContext, + postId, + clientId, + completionMode: settings.completionMode, + confidence: settings.confidence, + maxWords: 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(); + }, [ clearRequestTimeout ] ); + + const fetchSuggestion = useCallback( + async ( manual: boolean ) => { + const state = stateRef.current; + + if ( ! state.caret ) { + return; + } + + // Cancel any in-flight request + if ( abortControllerRef.current ) { + abortControllerRef.current.abort(); + } + clearRequestTimeout(); + + // Create new abort controller for this request + const controller = new AbortController(); + abortControllerRef.current = controller; + const currentRequest = ++requestRef.current; + requestTimeoutRef.current = window.setTimeout( () => { + controller.abort(); + }, REQUEST_TIMEOUT_MS ); + + try { + const response = await apiFetch< { + suggestion?: string; + confidence?: number; + } >( { + path: `/wp-abilities/v1/abilities/${ abilityName }/run`, + method: 'POST', + data: { + input: { + post_id: state.postId, + block_id: state.clientId, + 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, + }, + }, + signal: controller.signal, + } ); + + // Ignore if a newer request has been made + if ( currentRequest !== requestRef.current ) { + return; + } + + if ( + ! response || + typeof response !== 'object' || + ! 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 + ); + + setSuggestion( { + text: normalizedText, + confidence: Number( response.confidence || 0 ), + } ); + } catch ( error: unknown ) { + // Ignore aborted requests + 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; + } + clearRequestTimeout(); + } + }, + [ abilityName, clearRequestTimeout ] + ); + + const scheduleFetch = useCallback( + ( manual: boolean ) => { + // Clear any pending debounce timer + if ( debounceTimerRef.current !== null ) { + window.clearTimeout( debounceTimerRef.current ); + } + + if ( manual ) { + // Manual triggers bypass debounce + fetchSuggestion( true ); + return; + } + + // Debounce automatic triggers + const delay = Math.max( 200, settings.triggerDelay || 500 ); + debounceTimerRef.current = window.setTimeout( () => { + debounceTimerRef.current = null; + + const state = stateRef.current; + const contextTriggered = state.caret + ? shouldTriggerFromContext( state.caret.precedingText ) + : false; + + // In word mode, only trigger after sentence-ending punctuation + if ( state.completionMode === 'word' && ! contextTriggered ) { + return; + } + + fetchSuggestion( false ); + }, delay ); + }, + [ fetchSuggestion, settings.triggerDelay ] + ); + + // Trigger suggestion fetch when conditions are met + useEffect( () => { + if ( ! shouldRequest || ! caret || ! editable ) { + cancelPendingRequest(); + setSuggestion( null ); + return; + } + + scheduleFetch( false ); + + return cancelPendingRequest; + }, [ + shouldRequest, + caret?.offset, + plainContent, + requestNonce, + cancelPendingRequest, + scheduleFetch, + caret, + editable, + ] ); + + useEffect( () => { + if ( ! editable ) { + return; + } + + const handleInput = () => { + // Cancel pending request and clear suggestion when user types + cancelPendingRequest(); + setSuggestion( null ); + }; + editable.addEventListener( 'input', handleInput ); + return () => { + editable.removeEventListener( 'input', handleInput ); + }; + }, [ editable, cancelPendingRequest ] ); + + useEffect( () => { + if ( block ) { + block.classList.add( 'ai-type-ahead-block' ); + return () => block.classList.remove( 'ai-type-ahead-block' ); + } + }, [ block ] ); + + const insertText = useCallback( + ( text: string ) => { + if ( ! caret || ! editable ) { + return; + } + + const doc = caret.ownerDocument; + const sel = doc.getSelection(); + if ( ! sel || sel.rangeCount === 0 ) { + return; + } + const range = sel.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 ); + sel.removeAllRanges(); + sel.addRange( range ); + + const init: InputEventInit = { + bubbles: true, + cancelable: false, + data: text, + inputType: 'insertText', + }; + + const target = editable; + try { + const event = new InputEvent( 'input', init ); + target.dispatchEvent( event ); + } catch ( error ) { + const fallback = doc.createEvent( 'HTMLEvents' ); + fallback.initEvent( 'input', true, false ); + target.dispatchEvent( fallback ); + } + }, + [ 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, + } ); + } else { + setSuggestion( null ); + } + }, + [ insertText, 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(); + setRequestNonce( ( prev ) => prev + 1 ); + scheduleFetch( true ); + } + }; + + editable.addEventListener( 'keydown', handleKeyDown ); + return () => editable.removeEventListener( 'keydown', handleKeyDown ); + }, [ editable, suggestion, scheduleFetch, acceptSuggestion ] ); + + if ( ! allowedBlocks.has( name ) ) { + return ; + } + + return ( + <> + + + + { suggestion?.text ?? '' } + + + ); +}; + +const bootstrap = ( settings: TypeAheadSettings ) => { + if ( ! settings.enabled ) { + return; + } + + const allowed = new Set( ALLOWED_BLOCKS ); + if ( settings.showHeadings ) { + allowed.add( 'core/heading' ); + } + + const withTypeAhead = createHigherOrderComponent( + ( BlockEdit: React.ComponentType< any > ) => { + return ( props: any ) => ( + + ); + }, + 'withAITypeAhead' + ); + + addFilter( 'editor.BlockEdit', 'ai/type-ahead', withTypeAhead ); +}; + +const waitForSettings = ( attempts = 0 ) => { + const settings = window.aiTypeAheadData; + if ( settings ) { + bootstrap( settings ); + return; + } + + if ( attempts > 200 ) { + // About 5 seconds of polling; bail to avoid infinite loops. + return; + } + + window.setTimeout( () => waitForSettings( attempts + 1 ), 25 ); +}; + +waitForSettings(); diff --git a/src/experiments/type-ahead/style.scss b/src/experiments/type-ahead/style.scss new file mode 100644 index 00000000..aab08924 --- /dev/null +++ b/src/experiments/type-ahead/style.scss @@ -0,0 +1,14 @@ +.ai-type-ahead-block { + position: relative; +} + +.ai-type-ahead-overlay { + position: absolute; + pointer-events: none; + color: var(--ai-type-ahead-ghost-color, #8a8f98); + opacity: 1; + font-size: inherit; + line-height: inherit; + white-space: pre-wrap; + word-break: break-word; +} diff --git a/src/utils/run-ability.ts b/src/utils/run-ability.ts new file mode 100644 index 00000000..d5727380 --- /dev/null +++ b/src/utils/run-ability.ts @@ -0,0 +1,123 @@ +/** + * Safe ability execution helper. + * + * Uses the Abilities API client when it's available and falls back to REST calls + * when the client script hasn't been enqueued yet. + */ + +/** + * WordPress dependencies + */ +import apiFetch from '@wordpress/api-fetch'; +import { addQueryArgs } from '@wordpress/url'; + +type AbilityInput = + | Record< string, unknown > + | Array< unknown > + | string + | number + | boolean + | null + | undefined; + +type Method = 'GET' | 'POST' | 'DELETE'; + +type RunAbilityOptions = { + method?: Method; +}; + +let hasShownFallbackNotice = false; + +const getAbilityClient = () => + ( window as Record< string, any > )?.wp?.abilities ?? null; + +const logFallbackWarning = () => { + if ( hasShownFallbackNotice ) { + return; + } + + // eslint-disable-next-line no-console + console.warn( + '[AI Experiments] wp.abilities.executeAbility is unavailable. Falling back to REST.' + ); + hasShownFallbackNotice = true; +}; + +const isAbilityNotFoundError = ( error: unknown ): boolean => { + if ( ! error || typeof error !== 'object' ) { + return false; + } + + const message = + 'message' in error && typeof ( error as any ).message === 'string' + ? ( error as any ).message + : ''; + const code = + 'code' in error && typeof ( error as any ).code === 'string' + ? ( error as any ).code + : ''; + + return ( + code === 'ability_not_found' || message.includes( 'Ability not found' ) + ); +}; + +const buildFetchOptions = ( + ability: string, + input: AbilityInput, + method: Method +) => { + const normalizedInput = input ?? null; + + if ( method === 'GET' || method === 'DELETE' ) { + return { + path: + normalizedInput === null + ? `/wp-abilities/v1/abilities/${ ability }/run` + : addQueryArgs( + `/wp-abilities/v1/abilities/${ ability }/run`, + { + input: normalizedInput, + } + ), + method, + }; + } + + return { + path: `/wp-abilities/v1/abilities/${ ability }/run`, + method: 'POST' as const, + data: { + input: normalizedInput, + }, + }; +}; + +export async function runAbility< T = unknown >( + ability: string, + input?: AbilityInput, + options?: RunAbilityOptions +): Promise< T > { + const client = getAbilityClient(); + + if ( typeof client?.executeAbility === 'function' ) { + try { + return await client.executeAbility( ability, input ?? null ); + } catch ( error ) { + if ( ! isAbilityNotFoundError( error ) ) { + throw error; + } + logFallbackWarning(); + } + } else { + logFallbackWarning(); + } + + const method: Method = options?.method ?? 'POST'; + + const response = await apiFetch( + buildFetchOptions( ability, input, method ) + ); + + return response as T; +} diff --git a/webpack.config.js b/webpack.config.js index 49a296e9..f9b22017 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -29,6 +29,11 @@ module.exports = { 'src/experiments/title-generation', 'index.tsx' ), + 'experiments/type-ahead': path.resolve( + process.cwd(), + 'src/experiments/type-ahead', + 'index.tsx' + ), }, plugins: [