AI autocomplete

How to add AI autocomplete to the Gutenberg editor

Introduction

We’ve all been using AI-powered code completion in our IDEs. Imagine having something similar for the content you write on your WordPress website. Today, we’re going to build a small plugin that adds AI autocomplete to the Gutenberg editor

Prerequisites

You will need an OpenAI account and an API key to be able to generate content. If you already have an account, you can generate a key here.

I’ve simplified the example to be able to run, without any build steps. Ideally, for production you’d want to scaffold the initial block structure using something like the @wordpress/create-block package.

For the basic example, create a new directory in the plugins folder and add two empty files: ai-completions.php and index.js.

Building the back-end

Before we start building. Here’s a quick overview of the workflow we’re implementing: When the user pauses after typing a few words, we capture the paragraph text and send it to the OpenAI API. The API returns a short completion, which appears in the editor as a suggestion. If the user likes it, pressing the Tab key automatically accepts the text. Easy peasy.

The backend part is simple: we’re going to register a REST API endpoint for the editor to be able to send requests, the endpoint will send a call to the OpenAI API and return back the response.

Registering REST API endpoint

Let’s register our route via the register_rest_route() function. Registering routes must be done during or after the rest_api_init hook. So let’s add the following hook to the index.php file:

function ai_autocomplete_register_routes() {
    register_rest_route(
        'ai/v1',
        '/content',
        array(
            'methods'             => 'POST',
            'callback'            => 'ai_autocomplete_generate_content',
            'permission_callback' => function () {
                return current_user_can( 'edit_posts' );
            }
        )
    );
}
add_action( 'rest_api_init', 'ai_autocomplete_register_routes' );

Since WordPress 5.5.0 it is now required to add a permission callback, so in our case we are making sure that the calls are allowed only for users who are able to edit post. Kind of redundant, but whatever…

Callback function

When we registered the REST API route, we also provided a callback function ai_autocomplete_generate_content(). This function will receive the users input, generate a prompt and set it to the OpenAI’s completions endpoint.

The system prompt we’re going to use is:

Continue the following text. Offer no more than 2 sentences. Format your response in plain text, no HTML tags. Remove pre-text, post-text and prompt.

And this is just a quick example. You can expand this further and pre-train the model to follow your blog style or even perform additional tasks like avoiding some trademark terms in the response 😉

The user prompt will contain the users input. We want that for context, so that the AI knows what to write about. How do we get the user input? Our callback function has a WP_REST_Request parameter, which we can use to get the prompt:

$user_prompt   = apply_filters( 'the_content', $request->get_param( 'text' ) );

The reason for the_content filter is to make sure we format the text as closely as possible to user-readable format, we don’t want any Gutenberg block tags, which can confuse the AI.

Let’s create this function in the index.php file:

function ai_autocomplete_generate_content( WP_REST_Request $request ): WP_REST_Response {
    $system_prompt = 'Continue the following text. Offer no more than 2 sentences. Format your response in plain text, no HTML tags. Remove pre-text, post-text and prompt.';
    $user_prompt   = apply_filters( 'the_content', $request->get_param( 'text' ) );

    $response = wp_remote_post(
        'https://api.openai.com/v1/chat/completions',
        array(
            'headers' => array(
                'Content-Type'  => 'application/json',
                'Authorization' => 'Bearer ' . AI_API_KEY,
            ),
            'body'    => wp_json_encode(
                array(
                    'model'     => 'gpt-4o',
                    'messages'  => array(
                        array(
                            'role'    => 'system',
                            'content' => $system_prompt,
                        ),
                        array(
                            'role'    => 'user',
                            'content' => $user_prompt,
                        ),
                    )
                )
            ),
        ),
    );

    $body = json_decode( wp_remote_retrieve_body( $response ) );

    return rest_ensure_response( $body->choices[0]->message->content );
}

Oh, and one more thing. The call to OpenAI requires a token, which you should add as a define to the wp-config.php:

define( 'AI_API_KEY', 'sk-proj-*********' );

Registering the script

Of course, we also need to load our index.js script. Super easy to do with the wp_enqueue_script() function:

function ai_autocomplete_plugin_script() {
    wp_enqueue_script(
        'ai-autocomplete',
        plugins_url( '/index.js', __FILE__ ),
        array(
            'wp-api-fetch', 'wp-block-editor', 'wp-compose', 'wp-element', 'wp-hooks',
        ),
    );
}

add_action( 'init', 'ai_autocomplete_plugin_script' );

Because we’re not building this script, we also need to provide an array of dependencies that should be loaded prior to executing the script. This isn’t really a requirement, because chances are of those not being loaded on the editor are slim to none. But let’s be diligent.

Building the front-end

The idea is simple – a user writes something in the editor, pauses for a couple of seconds, the users input is forwarded to our REST API endpoint, which returns the completion, which is then offered to the user as a suggestion. If a user presses the TAB key, the proposed text is accepted and added to the editor. To make sure that we don’t spam the API, we’re going to require at least 5 new words after the last suggestion.

Variables and helper functions

As mentioned previously, we’re setting a word limit, adding an ID to be able to identify our suggestion and adding some styles so that the suggestion stands out in the editor:

const NEW_WORDS_LIMIT = 5;
const TEXT_ID = 'ai-completion';
const TEXT_STYLE = "color: #FF6347;";

Next, we will need to know the word count. Super easy – split the string into words, and count them:

const getWordCount = ( content ) => {
  return content.trim().split( /\s+/ ).length;
};

Lastly, our helper method to send the content to the REST API endpoint:

const generateContent = ( text ) => {
  return wp.apiFetch( {
    path: '/ai/v1/content',
    method: 'POST',
    data: { text },
  } );
};

The BlockEdit filter

WordPress exposes several APIs that allow modifying the behavior of existing blocks. The BlockEdit filter is used to modify the block’s edit component. It receives the original block BlockEdit component and returns a new wrapped component. TLDR; we can get the content of the paragraph block. Let’s add the filter:

wp.hooks.addFilter(
    'editor.BlockEdit',
    'ai_autocomplete/text-completion',
    withAIAutoComplete,
    11
);

This follows a similar pattern for hooks on the back-end. One notable difference between the JS and PHP hooks API is that in the JS version, addAction() and addFilter() also need to include a namespace as the second argument. Namespace uniquely identifies a callback in the form vendor/plugin/function.

Callback function

The callback function accepts the newContent as the parameter, this is the text inside the paragraph block. It looks like this:

const handleContentChange = ( newContent ) => {
  // Do the logic here.

  // Don't forget to store the paragraph in the content state.
  props.setAttributes( { content: newContent } );
};

The callback function should return a modified version of the paragraph block with our AI suggestion (if present) and it should also track user action (the TAB key press). In order for us to do that, we will need to use the createHigherOrderComponent function, which looks something like this:

const withAIAutoComplete = wp.compose.createHigherOrderComponent( ( BlockEdit ) => {
  return ( props ) => {
    // All the following code will go here.

    return wp.element.createElement( wp.blockEditor.RichText, {
      ...props,
      tagName: 'p',
      value: props.attributes.content,
      onChange: handleContentChange,
      onKeyDown: handleKeyDown,
    } );
  };
}, 'withAIAutoComplete' );

First thing we want to do is process only paragraph blocks. You can extend this to other blocks as well, but for the sake of this example, let’s only process paragraph blocks. To do that, we can simply check the name of the block, using the name attribute inside the props object:

if ( props.name !== 'core/paragraph' ) {
  return wp.element.createElement( BlockEdit, { ...props } );
}

If it’s not a paragraph block – pass back a block with all original props.

Our modified block will also need some new states:

const [ aiSuggestion, setAISuggestion ] = wp.element.useState( '' );
const [ lastWordCount, setLastWordCount ] = wp.element.useState( 0 );
const debounceTimer = wp.element.useRef( null );

Kind of self explanatory, except, maybe the debounceTimer. We’re going to use that to store our timer. By using a useRef, the timer can persist without being reset on every re-render. It ensures that the API is not called too often (e.g., every time the user types a single character).

Handling the onChange event

This event is triggered every time a user presses a button (types a character in the editor). As mentioned before, we need to wait for the user to pause typing, so if we already have a timer running – clear it. Make sense? User typed a character – timer is reset, and so on until the timer has enough time to run and trigger the API call:

if ( debounceTimer.current ) {
  clearTimeout( debounceTimer.current );
}

Next we need to check how many words have been typed and if we’re above our predefined NEW_WORDS_LIMIT:

const currentWordCount = getWordCount( newContent );

if ( currentWordCount - lastWordCount < NEW_WORDS_LIMIT ) {
  props.setAttributes( { content: newContent } );
  return;
}

And finally, our timer logic. Again, fairly easy if you’re familiar with timers in JavaScript. All we’re doing is setting a timeout for 2 seconds, once the timer reaches the end – generate our content, save it in the aiSuggestion state var and append a styled span element to the end of the paragraph block:

debounceTimer.current = setTimeout( () => {
  generateContent( newContent ).then(( suggestion ) => {
    setAISuggestion( suggestion );
    const suggestionSpan = `<span id="${TEXT_ID}" style="${TEXT_STYLE}">${suggestion}</span>`;
    props.setAttributes( { content: newContent + suggestionSpan } );
    setLastWordCount( currentWordCount );
  } );
}, 2000 );

Oh, and we’re also making sure to update the word count.

Handling the onKeyDown event

If we have an active AI suggestion in our aiSuggestion state var from the previous step and the user presses the TAB button on the keyboard, we will remove all the styles we’ve added to the text and append it to the paragraph block.

const handleKeyDown = ( event ) => {
  if ( ! aiSuggestion ) {
    return;
  }

  let replacementText = '';
  if ( event.key === 'Tab' ) {
    event.preventDefault();
    replacementText = aiSuggestion;
  }

  const contentWithoutAI = props.attributes.content.replace(
      new RegExp( `<span id="${TEXT_ID}"[^>]*>.*?<\\/span>`, 'g' ),
      replacementText
  );

  props.setAttributes( { content: contentWithoutAI } );
  setAISuggestion( '' );
  setLastWordCount( getWordCount( contentWithoutAI ) );
};

That’s it. We now have AI autocomplete functionality directly inside our editor. Interested in taking this further? Experiment with different OpenAI models (add reasoning with GPT-o1), or extend autocomplete functionality to other block types like headings and quotes. With minor tweaks, you could even implement AI-based translations or real-time grammar checks. The sky’s the limit once you have the basic AI plugin infrastructure in place.

Interested in custom code development, reach out via our contact form here.

Full code