You’ve probably heard a lot about AI and its uses across industries, including how it can be used to write code. There’s a lot of uncertainty about what its role is going to be,  but it’s pretty clear that it will be present in the modern developer’s workflow from this point on, to some degree. Here at Chee, we’ve been pretty jazzed about the benefits that generative AI and its associated tools have provided to us so far, and we’re eager to continue to integrate them. We thought we’d give you a primer on a use case, so that you could try integrating it into a project yourself.

What You’re Going To Build

This tutorial is going to guide you on how to build a custom native WordPress block using React and integrate OpenAI’s “Completion” API* to be able to query ChatGPT from right within the WordPress dashboard. To get the most out of this tutorial, it’s best to follow along step-by-step, but there will also be a link to the plugin on GitHub at the end.

*this is not the ChatCompletion API, which you would use to create an actual chatbot similar to the GPT interface. I recommend learning the difference between the two!

Requirements/Prerequisites

To successfully complete this tutorial, it’s recommended you have:

  • A solid understanding of HTML/CSS/JS (ES6)
  • Some familiarity with React
  • And by extension, some knowledge of writing WordPress Blocks

First things first…

Run WordPress’ create-block package

Choose your preferred method of getting a WordPress instance running. We’re going to begin by using the WordPress create-block package. Whether you choose to keep it as a plugin, or integrate the block into your own theme, is entirely up to you.

Install OpenAI package from NodeJS

Navigate to your new block directory and run:

npm install openai

Get your OpenAI API Key

Head over to https://platform.openai.com/account/api-keys to generate and obtain an OpenAI API key.

 

OK, now let’s build!

Fire up your favorite code editor inside your new blocks directory and run this command to begin the build process:

npm start

In your new custom block folder, you will find something similar to the following folder/file structure:

├── build
├── src
│   ├── block.json
│   ├── edit.js
│   ├── save.js
│   ├── index.js
│   ├── editor.scss
│   ├── style.scss
├── gpt-block.php
├── package.json

Since this tutorial assumes you know the basic anatomy of a WordPress block, we’re not going to cover what each of these files do (and the official docs are quite good for this, as well). Since our block is a generative copy tool, we’re not going to be focusing much on the save.js file/function, and instead we’ll be doing the majority of our work in the edit.js file.

Set up imports and configure OpenAI

To begin with, we’re going to set up our imports. At the top edit.js, let’s get our basic imports going:

/* Imports
========================================================= */
import { __ } from '@wordpress/i18n';
import { useBlockProps } from '@wordpress/block-editor';
import { useState} from '@wordpress/element';
import { Configuration, OpenAIApi } from 'openai';
import './editor.scss';

Now we can configure OpenAI:

/* edit.js
========================================================= */

export default function Edit() {

	/* OpenAI Init */

	const configuration = new Configuration({
		organization: "", // optional
		apiKey: YOUR_API_KEY,
	});
	delete configuration.baseOptions.headers['User-Agent']; // optional, suppresses User Agent error for localhost
	const openai = new OpenAIApi(configuration);

	/* Render */
	
	return (

		<div {...useBlockProps()}>
			
		</div>

	);
}

State/Props

Let’s move onto state management.  We’re going to need to manage state for:

  • Questions that the user inputs
  • Answers that are received from OpenAI

Right after our OpenAI configuration, let’s add:

/* Form State */

	const [newQuestion, setNewQuestion] = useState("");
	const [newAnswer, setNewAnswer] = useState("");

User Form Input

Any good chat prompt needs a form input! In this case, we need a basic form with a simple text field and a submit button. Ideally, this should be it’s own component, so let’s create a file in the root and call it FormInput.js. For now, we’ll just write the bones of it. We know we’re going to need this component to update the newQuestion state, so let’s destructure that right away and ensure that we have our Textarea update that state with the user’s question.

/* Imports
========================================================= */
import { useBlockProps } from '@wordpress/block-editor';
import { Button, TextareaControl } from '@wordpress/components';

/* Function
========================================================= */
export default function FormInput({ newQuestion, setNewQuestion }) { 

  /* Form Handler */

  const handleSubmit = (e) => {
    e.preventDefault();
    console.log('form submitted');
  };

  /* Render */

  return (
    <form>

      <TextareaControl
        type="text"
        rows={5}
        onChange={(input) => setNewQuestion(input)}
        placeholder="What do you need help with?"
      />
      
      <Button
        onClick={handleSubmit}
        variant="primary"
      >Ask ChatGPT</Button>

    </form>
  )

}

OpenAI Response Request

Now that we have an interactive component ready for user input, let’s head back to edit.js and write our OpenAI request function. This should all be fairly self explanatory, as we’re going to configure an async and generate a promise with the request, making sure to pass in our newQuestion prop directly to the request. I passed in some starter options but you can read up on the full list of options. We’re going to be using the text-davinci-003 model, which is basically the same as GPT 3.5*.

/* Generate ChatGPT Response */

	const generateResponse = async (newQuestion) => {

		await openai.createCompletion(
			{
				model: "text-davinci-003",
				prompt: newQuestion,
				temperature: 0.7,
				max_tokens: 500
			}
		)
			.then((response) => {
				setNewAnswer(response.data.choices[0].text);
			})
			.catch((error) => {
				console.log(error);
			})
	};

*it is also possible to adapt this code for GPT4, but since GPT 3.5 is much faster, we’re going to stick with this model for the purposes of this tutorial

Hooking up Form to Function

Now that we have our response function completed, let’s head back to FormInput.js and update our form handler. We want to pass in generateResponse as a function prop, along with hooking it up to the handleSubmit form handler function. This will update the values of these props, which our parent component will be able to access when the form is submitted. Additions/changes have been highlighted:

/* Imports
========================================================= */
import { useBlockProps } from '@wordpress/block-editor';
import { Button, TextareaControl } from '@wordpress/components';

/* Function
========================================================= */
export default function FormInput({ newQuestion, setNewQuestion, generateResponse }) { 

  /* Form Handler */

  const handleSubmit = (e) => {
    e.preventDefault();
    generateResponse(newQuestion);
    setNewQuestion("");
  };

  /* Render */

  return (
    <form>

      <TextareaControl
        type="text"
        rows={5}
        onChange={(input) => setNewQuestion(input)}
        placeholder="What do you need help with?"
      />
      
      <Button
        onClick={handleSubmit}
        variant="primary"
      >Ask ChatGPT</Button>

    </form>
  )
}

Create Block Markup

We have most of our “behind the scenes” configuration ready to go, so now is a good time to write the markup for the WordPress block, as well as implementing the form input.

  • The FormInput should reside in the sidebar, which we place there using InspectorControls
  • The generated text will be output in a container inside the main Block Editor
  • We need to check for the existence of an answer output in the main editor, and if there is none, then output some placeholder copy
  • We need to pass our our newQuestion, setNewQuestion, and generateResponse props to FormInput

Here’s what edit.js will look like after we implement these changes. Additions/changes have been highlighted:

/* Imports
========================================================= */
import { __ } from '@wordpress/i18n';
import { useBlockProps, InspectorControls } from '@wordpress/block-editor';
import { Panel, PanelBody } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { Configuration, OpenAIApi } from 'openai';
import FormInput from './FormInput';
import './editor.scss';

/* Function
========================================================= */

export default function Edit() {

/* OpenAI Init */

const configuration = new Configuration({
organization: "", // optional
apiKey: YOUR_API_KEY,
});
delete configuration.baseOptions.headers['User-Agent']; // suppress User Agent error for localhost
const openai = new OpenAIApi(configuration);


/* Form State */

const [newQuestion, setNewQuestion] = useState("");
const [newAnswer, setNewAnswer] = useState("");

/* Generate ChatGPT Response */

const generateResponse = async (newQuestion) => {

await openai.createCompletion(
	{
		model: "text-davinci-003",
		prompt: newQuestion,
		temperature: 0.7,
		max_tokens: 500
	}
)
	.then((response) => {
		setNewAnswer(response.data.choices[0].text);
	})
	.catch((error) => {
		console.log(error);
	})
};

/* Render */

return (

<div {...useBlockProps()}>
	<div className="gpt-block-wrap">

		<div>
			{newAnswer.length >= 1 ?
				<div className="generated-text">
					<h3>Your Result:</h3>
					<div className="gpt-copy">
						{newAnswer}
					</div>
				</div>
				:
				<div>
					<small>ChatGPT Block:</small>
					<h6>Awaiting your question...</h6>
				</div>
			}
		</div>

		<InspectorControls>
			<PanelBody title={__('Ask a Question', 'gpt-block')}>
				<Panel>
					<FormInput
						newQuestion={newQuestion}
						setNewQuestion={setNewQuestion}
						generateResponse={generateResponse} />
				</Panel>
			</PanelBody>
		</InspectorControls>

	</div>
</div>
);
}

We now have the main engine of our ChatGPT plugin running, and we should be able to query the OpenAI endpoint and receive a response, although it’s not much to look at right now:

Let’s add some basic styles by adding this to our editor.scss:

.gpt-block-wrap {
	padding: 30px;
	margin: 40px;
	background-color: #eee;
	border:1px solid #ccc;
	h3 {
		font-size: 16px;
		border:1px bottom dashed #ccc;
	}
	h6 {
		color: #333;
		margin:0 !important;
	}
	small {
		display: block;
		color: #aaa;
		text-transform: uppercase;
		letter-spacing: 2px;
		margin-bottom: 10px;
	}
	.gpt-response-copy {
		font-size: 14px;
		line-height: 1.6;
		color: #555;
		font-style: italic;
	}
}

Compile our block once more, and things are looking a lot more professional:

And we’re done! Or…are we?

We could stop here, as things are working: If you type a statement, you get a response…but we would be remiss to not add some UX sugar on top, as well as adding some best practices for React development.

Bonus Round One: Loading Icon

If you submit the form currently, there is an awkward pause while the response is being generated. This isn’t a good user experience, as the user would not be sure if they did something wrong or something was just broken and not working while they waited. Let’s add a loading state to our component to provide that feedback to the user.

To start, we’ll need some state we can access:

/* Loading */

const [isLoading, setIsLoading] = useState(false);

And then we’re going to modify the generateResponse function to set that state to true upon beginning, and false when it’s resolved the promise. Additions/changes have been highlighted.

/* Generate ChatGPT Response */

const generateResponse = async (newQuestion) => {

  setIsLoading(true);

  await openai.createCompletion(
    {
      model: "text-davinci-003",
      prompt: newQuestion,
      temperature: 0.7,
      max_tokens: 500
    }
  )
    .then((response) => {
      setNewAnswer(response.data.choices[0].text);
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      setIsLoading(false);
    })
};

With our state being managed and toggled with the right conditions, we can implement it into our component return. WordPress has a built in Spinner, so let’s import that from the Components repo:

import { Panel, PanelBody, Button, Spinner } from '@wordpress/components';

And finally, we’ll implement the conditional along with the Spinner component:

/* Render */

return (

  <div {...useBlockProps()}>
    <div className="gpt-block-wrap">
      {isLoading ?
        <>
          <small className="loading-header">Thinking...</small>
          <Spinner />
        </>
        :
        <>
          {newAnswer.length >= 1 ?
            <div className="generated-text-container">
              <h3>Your Result:</h3>
              <div className="gpt-response-copy">
                {newAnswer}
              </div>
            </div>
            :
            <div>
              <small>ChatGPT Block:</small>
              <h6>Awaiting your question...</h6>
            </div>
          }
        </>
      }

      <InspectorControls>
        <PanelBody title={__('Ask a Question', 'gpt-block')}>
          <Panel>
            <FormInput
              newQuestion={newQuestion}
              setNewQuestion={setNewQuestion}
              generateResponse={generateResponse} />
          </Panel>
        </PanelBody>
      </InspectorControls>

    </div>
  </div>

);

And let’s center the loading element along with the icon (note: your classes might differ slightly):

.wp-block-gptblock-gpt-block {
	svg {
		display: block;
		margin: 0 auto;
	}
	.loading-header {
		display: block;
		text-align: center;
	}
 }

And with that, we have a styled and user-friendly ChatGPT interface right within our Block Editor:

 

The copy it’s generating is useful, but what if I wanted to easily copy it for use elsewhere around the site? A “Copy Text” button would be pretty sweet, as well. So, if you’re still game for more, let’s move onto the next bonus round.

Bonus Round Two: Copy Text Button

As usual, let’s modify our imports to include the WordPress Button component, as well as useRef, which will use to reference the Textarea element.

import { Panel, PanelBody, Button, Spinner } from '@wordpress/components';
import { useState, useRef } from '@wordpress/element';

And we’ll need some state management, along with the reference variable:

/* Form State */
const [newQuestion, setNewQuestion] = useState("");
const [newAnswer, setNewAnswer] = useState("");
const [isCopied, setisCopied] = useState(false);

/* Refs */
const textRef = useRef();

And let’s place a Copy to Clipboard function. You can write one of these fairly easily, or find them all around the internet (including having something like ChatGPT or CoPilot assist you). We’re leaning on the contemporary navigator.clipboard method (which does not work unless the site is running HTTPS):

/* Copy to Clipboard */

const copyToClipboard = () => {
  const generatedText = textRef.current;
  if (generatedText) {
    if (navigator.clipboard) {
      navigator.clipboard.writeText(generatedText)
        .then(() => {
          alert('Copied to clipboard!');
          setisCopied(true);
        })
        .catch((error) => {
          console.error('Failed to copy text: ', error);
          setisCopied(false);
        });
    }
  }
};

And finally, we can implement our Button to appear right below the generated copy from ChatGPT:

<>
{newAnswer.length >= 1 ?
  <div className="generated-text-container">
    <h3>Your Result:</h3>
    <div className="generated-gpt-copy" ref={textRef} style={{ marginBottom: "15px" }}>
      {newAnswer}
    </div>
    <Button
      variant="secondary"
      icon="admin-page"
      onClick={copyToClipboard}>
      {isCopied ? 'Copied!' : 'Copy Text'}
    </Button>
  </div>
  :
  <div>
    <small>ChatGPT Block:</small>
    <h6>Awaiting your question...</h6>
  </div>
}
</>

Users can easily generate copy and now copy that text for use around the site:

 

From a UI/UX perspective, this feels fairly complete and we could stop here from a functional angle and say it’s done…but that wouldn’t be the entire truth, as there’s some improvements we can make to the React code itself to ensure it’s efficient and not suffering from unnecessary renders and possible memory leaks. If you’re sticking with me until this point, congrats! Let’s move onto our final round of edits.

Bonus Round Three: Memoizing our functions

The nature of React is to render components only when React sees a change, but that can be complicated with the parent/child component relationship. React provides us a couple hooks that we can implement to make sure these re-renders only happen when there is a pertinent change within the component/functions itself. Let’s first implement useCallback in our edit.js file.

As usual, we’ll first modify our imports:

import { useState, useRef, useCallback } from '@wordpress/element';

Implementation of this hook is simple, as we’ll wrap it around our generateResponse function to memoize it. Since we don’t want this function to run on every render, but instead anytime a new request is made to openai, we’ll add that into the dependency array as the second argument to this hook:

/* Generate ChatGPT Response */

const generateResponse = useCallback(async (newQuestion) => {

  setisCopied(false);
  setIsLoading(true);

  await openai.createCompletion(
    {
      model: "text-davinci-003",
      prompt: newQuestion,
      temperature: 0.7,
      max_tokens: 500
    }
  )
    .then((response) => {
      setNewAnswer(response.data.choices[0].text);
    })
    .catch((error) => {
      console.log(error);
    })
    .finally(() => {
      setIsLoading(false);
    })
}, [openai]);

This takes care of memoizing our generateResponse function, so let’s pop over to edit.js and see if we can improve anything there. handleSubmit is another function that we don’t want to run on each render, but only if one of the included functions happens to change. Here’s our updated handleSubmit function, wrapped in our useCallback with the appropriate dependencies.

const handleSubmit = useCallback((e) => {
  e.preventDefault();
  generateResponse(newQuestion);
  setNewQuestion("");
}, [setNewQuestion, generateResponse]);

That’s a wrap!

We’ve successfully integrated a state-of-the-art AI platform right within the WordPress block editor. These new tools offer lots of exciting possibilities. Hopefully this is just the beginning of your journey into how these tools work and how you will be able to leverage them moving forward. I’ve already had a number of ideas on how to extend this block, including modifications that would allow for inline text formatting, syntax highlighting, integration of GPT4, and more! Although, that’s a post for another day.

View Full Code

For those of you who want to dig into the code right away, here’s a GitHub link:
https://github.com/cheestudio/chatgpt-wordpress-block