Yhtä LinkedIn kirjoitusta lukiessa inspiroiduin leikkimään ChatGPT:n rajapinnoilla ja illan (meni vähän aamunkin puolelle) aiheeseen uppouduttua syntyi lohkoeditoriin sisältöä generoiva AI-avustaja. Kutsun tätä lohkojen puolella AI Content Assistant -nimellä ja käytännössä tämä hakee ChatGPT:stä halutun sisällön sekä muuntaa sen WordPressin lohkoiksi. Toiminnallisuus itsessään on vielä pienesti konseptin tasolla, mutta käydään vähän läpi miten tuo on toteutettu.

Taustan (backendin) toteutus

Ennen kuin pystymme toteuttamaan varsinaisen lohkon, tarvitsemme taustalle hieman toiminnallisuuksia. Ensinnäkin, jotta ChatGPT:n API-avaimia ei tarvitse paljastaa frontissa, toteutamme oman kustomoidun rajapinnan, josta pystymme hakemaan ChatGPT:n sisältöä.
Tässä hyödynnetään hieman Kotisivu Block Themen API-rajapintojen luomiseen käytettyä luokkaa, mutta pohjalla on kokoajan WordPressin oma register_rest_route -funktio. Tämä näyttää käytännössä tältä. Varsinaiseen luokkaan pääset täältä.

register_rest_route(
   RouteInterface::NAMESPACE . RouteInterface::VERSION,
   '/' . $this->base . '/generate',
   array(
       'methods'             => \WP_REST_Server::EDITABLE, // Alias for GET transport method.
       'callback'            => array( $this, 'generate_open_ai_content' ),
       'permission_callback' => Permission::ADMIN->get_callback(),
   )
);

Varsinainen funktio, jolla kutsutaan ChatGPT:n rajapintaa on seuraavanlainen.

/**
* Get content from Open AI
* @param \WP_REST_Request $request Request object.
* @return array
* @throws \Exception If failed to update contact information.
*/
public static function get_open_ai_content( \WP_REST_Request $request ): array {
   $body = json_decode( $request->get_body(), true );
   $api  = UtilsOptions::get_chatgpt_settings();
   $data = array(
       'model'    => $api['model'] ?? 'gpt-4o-mini',
       'messages' => array(
           (object) array(
               'role'    => 'system',
               'content' => 'You are a helpful assistant that returns text in markdown format.',
           ),
           (object) array(
               'role'    => 'user',
               'content' => $body['prompt'],
           ),
       ),
   );


   $response = wp_remote_post(
       'https://api.openai.com/v1/chat/completions',
       array(
           'method'    => 'POST',
           'body'      => wp_json_encode( $data ),
           'headers'   => array(
               'Content-Type'  => 'application/json',
               'Authorization' => 'Bearer ' . $api['api_key'],
           ),
           'sslverify' => false,
           'timeout'   => 60,
       ),
   );


   if ( ! is_wp_error( $response ) ) {
       $body = json_decode( wp_remote_retrieve_body( $response ), true );
       return $body;
   } else {
       $error_message = $response->get_error_message();
       throw new \Exception( $error_message );
   }
}

Paloiksi rikottuna:

  1. Haemme ensin json_decode( $request->get_body(), true ); käyttäjän syöttämän syötteen
  2. Tarvitsemme myös tietokantaan tallennetun API-avaimen, jota varten kutsumme apufunktiota UtilsOptions -luokasta. 
  3. Seuraavaksi täytyy määrittää ChatGPT:n kutsuun käytettävät ajatukset. Tämä tieto tallennetaan $data -muuttujaan. Huomioi, että ohjeistamme ChatGPT:tä palauttamaan tieto markdown-formaatissa, jotta pystymme käsitellä palautetun datan paremmin.
  4. Viimeisenä tehdään kutsu ChatGPT:n rajapintoihin.

Asetussivuja voi toteuttaa hyvin monella tapaa ja näihin löytyy hyviä ohjeita. Käytännössä tämän tarkoituksena on vain se, että teemaan itsessään ei tarvitse tallentaa mitään API-avaimia, vaan teeman käyttäjä voi syöttää tämän itse. Kotisivu Block Theme käyttää REST-api ja React-pohjaista asetussivua, josta löytyy malli täältä.

Nyt meillä on valmis rajapinta, jota voimme kutsua turvallisesti ja voimme siirtyä varsinaisen lohkon toteutukseen.

Lohkon (frontend) toteutus

Lohkon pohjalla oleva ajatus on käytännössä hyvinkin yksinkertainen ja pohjaa sisäisiin lohkoihin (InnerBlocks). Lohkon koko koodin pääset näkemään täältä. Kaikki suola ja hienoudet on määritetty edit.tsx -tiedostoon, kuitenkin jakamalla koodia pariin aputiedostoon, jotta tämä olisi hieman luettavampaa. Siirrytään seuraavana toteutukseen:

Ensimmäinen vaihe oli saada jokin tekstikenttä ja painike, jolla käyttäjä pystyy syöttämään pyyntöjä ChatGPT:n puolelle. Tähän toteutin yksinkertaisen lomakkeen lohkon Inspector-valikkoon, johon kytkeytyi pari muuttujaa. Näitä oli handleContentCallback -funktio, jolla käsitellään käyttäjän tekemä pyyntö (tästä lisää kohta) ja status, jonka avulla viestitään käyttäjälle, että jotain tapahtuu ruudulla ja esimerkiksi hänen pyyntöään käsitellään.

/**
* WordPress dependencies
*/
import { __ } from '@wordpress/i18n';
import { InspectorControls } from '@wordpress/block-editor';


/**
* Internal dependencies
*/
import { Ring180 } from '@icons';


/**
* Inspector controls
* @param {Record<string, any>} props Component properties
* @return {JSX.Element} Inspector controls
*/
const Inspector = ({
   handleContentCallback,
   status,
}: {
   handleContentCallback: React.FormEventHandler<HTMLFormElement>;
   status: boolean;
}): JSX.Element => {
   return (
       <>
           <InspectorControls>
               <form
                   className="ai-content-assistant-controls"
                   onSubmit={handleContentCallback}
               >
                   <div className="components-base-control">
                       <div className="components-base-control__field">
                           <label
                               className="components-base-control__label"
                               htmlFor="prompt"
                           >
                               {__('Prompt', 'kotisivu-block-theme')}
                           </label>
                           <textarea
                               id="prompt"
                               className="components-text-control__input"
                               name="prompt"
                               rows={4}
                           ></textarea>
                       </div>
                       <p className="components-base-control__help">
                           {__(
                               'Enter a prompt to generate content. Please do remember that AI generated content may not be accurate. Always review the content before publishing.',
                               'kotisivu-block-theme'
                           )}
                       </p>
                   </div>
                   <button
                       type="submit"
                       className="components-button is-primary is-compact"
                       disabled={status}
                   >
                       {status ? (
                           <Ring180 />
                       ) : (
                           __('Generate', 'kotisivu-block-theme')
                       )}
                   </button>
               </form>
           </InspectorControls>
       </>
   );
};


export default Inspector;

Varsinainen handleContentCallback-funktion toiminnallisuus löytyy edit.tsx -tiedostosta ja näyttää kutakuinkin tältä.

/**
* Handle ChatGPT add content
* @param {React.FormEvent<HTMLFormElement>} event
*/
async function handleAddContent(event: React.FormEvent<HTMLFormElement>) {
   event.preventDefault();


   /**
    * Handle initial loading state and form data
    */
   setIsLoading(true);
   const formData = new FormData(event.currentTarget);
   const prompt = formData.get('prompt');


   /**
    * Handle prompt validation
    * TODO: window.alert is not recommended, use a proper validation method
    */
   if (!prompt) {
       // eslint-disable-next-line no-alert
       window.alert(__('Please enter a prompt', 'kotisivu-block-theme'));
       setIsLoading(false);
       return;
   }


   /**
    * Clear existing blocks
    */
   replaceInnerBlocks(clientId, []);


   /**
    * Fetch response from API
    */
   const response: Response = await apiFetch({
       method: 'POST',
       path: 'kotisivu-block-theme/v1/ai/generate',
       data: {
           prompt,
       },
   });


   /**
    * Handle adding blocks to the editor
    */
   const blocks = await parseMarkdownToBlocks(
       response.choices[0].message.content
   );


   blocks.forEach((block, index) => {
       insertBlock(block, index, clientId);
   });


   setIsLoading(false);
}

Pala kerrallaan tapahtuu seuraavaa:

  1. Ensin käsitellään ja haetaan käyttäjän syöte
  2. replaceInnerBlocks(clientId, []); avulla tyhjennetään lohkon aiempi sisältö
  3. Seuraavana tehdään varsinainen pyyntö palvelimelle aiemmin määritetyn rajapinnan kautta. Tämä palauttaa ChatGPT:n vastauksen ja on käytännössä muotoa tämä.
  4. Koska lohkoeditori ei suoraan ymmärrä ChatGPT:n vastausta, meidän täytyy vielä luoda lohkot ja parsia sisältö saadusta datasta. Tässä hyödynnämme parseMarkdownToBlocks -apufunktiota, josta tarkemmin myöhemmin.
  5. Viimeisenä täytyy lisätä lohkot lohkoeditoriin. Voimme tässä käydä parseMarkdownToBlocks -funktiolla luodun listan läpi ja lisätä vuoronperään jokaisen luodun lohkon editoriin. Huomaa clientId -käyttö, joka tarvitaan siihen, että sisältö lisätään oikean lohkon alle.

Muu lohkon toiminta on täysin kuin minkä tahansa muun InnerBlocksia luodun lohkon toiminta eli tässä ei mitään erikoista.

Sisällön parsiminen

Ajallisesti aiemmat vaiheet olivat selvästi ne helpoimmat osuudet ja eniten aikaa kului siihen, että saadaan ChatGPT:n tarjoama data muunnettua oikeiksi lohkoiksi ja formatoitua lohkot oikein. Tähän tarvitsemme vähän lisää sääntöjä ja apufunktioita, jotka määritellään tiedostossa scripts.ts. Pääset tiedostoon täältä.

Päällimmäisenä on aiemmin mainittu parseMarkdownToBlocks -funktio, joka näyttää seuraavanlaiselta.

/**
* Parse markdown to blocks
* @param {string} markdown Markdown string
* @return {Promise<BlockInstance[]>} Array of block instances
*/
async function parseMarkdownToBlocks(
   markdown: string
): Promise<BlockInstance[]> {
   /**
    * Index to skip to in the markdown string. Used for list items to prevent re-adding the same block
    * @type {number}
    */
   let skipTo: number = 0;


   /**
    * Split the markdown string into blocks
    * @type {string[]}
    */
   const list: string[] = markdown.split('\n') || [];


   /**
    * Loop thorugh the blocks and add blocks based on the block type
    */
   const blocks: BlockInstance[] = [];


   for (let i = 0; i < list.length; i++) {
       // ChatGPT sometimes adds empty lines, skip them
       if (list[i] === '') {
           continue;
       }


       // Some blocks like list items can take multiple lines.
       // Prevent re-adding the same block by skipping to the next index
       if (skipTo > i) {
           continue;
       }


       // Handle adding blocks based on the block type
       const trimmedBlock = list[i].trim();


       switch (true) {
           case trimmedBlock.startsWith('-'):
               const listItems = handleListItems(list, i);
               skipTo = listItems.innerBlocks.length + i;
               blocks.push(listItems);
               break;
           case trimmedBlock.startsWith('#'):
               const level = trimmedBlock.match(/#/g)?.length;
               const content = trimmedBlock.replace(/#/g, '').trim();
               blocks.push(
                   createBlock('core/heading', {
                       content: handleTextFormatting(content),
                       level: level !== undefined ? level : 2,
                   })
               );
               break;
           default:
               if (trimmedBlock === '') {
                   continue;
               }


               blocks.push(
                   createBlock('core/paragraph', {
                       content: handleTextFormatting(trimmedBlock),
                   })
               );
               break;
       }
   }


   return blocks;
}

Jotta kaikki saatiin toimintaan, täytyi kirjoittaa hieman monimutkaisempaa logiikkaa ja tarkistuksia. Käydään läpi mitä tässä tapahtuu.

  1. Ensimmäisenä määritetään muutama muuttuja, jolla saadaan muovattua for-looppien toimintaa ja varmistettua, että jo lisättyjä lohkoja ei lisätä uudestaan. Näistä ensimmäinen on skipTo -muuttuja, jolla pidetään huoli, että jos menemme hetkeksi sisäkkäiseen looppiin (esimerkiksi listojen kohdalla), funktio osaa varmasti jatkaa oikeasta kohdasta lohkojen lisäystä. Muuttujat list ja blocks hyvin yksinkertaisia. lists on vain lista sisällöistä, joka on eroteltu newline (\nl) merkin avulla. blocks on väliaikainen muuttuja, jonne tallennetaan parsitut lohkot.
  2. Seuraavana mennään varsinaisen loopin sisälle. Täällä on pari Guard Clause sääntöä eli jos muuttujan arvo on tyhjä (ChatGPT saattaa palauttaa tyhjiä rivejä), skipataan heti seuraavaan tai jos aiemmin määrätty skipTo -muuttuja on määrätty isommaksi kuin loopin nykyinen indeksi, mennään seuraavaan kunnes looppi on saanut oikean kohdan kiinni.
  3. Seuraavana lohkosta siistitään vielä tyhjät merkit, jotta käyttäytyminen on paremmin ennalta-arvattavampaa.

Tästä päästään varsinaisen loopin sisälle, jossa ehdollisesti käsitellään eri tyylinen sisältö. Näistä paragraph ja heading lohkot ovat yksinkertaisia, mutta listojen käsittely on hieman monimutkaisempaa. Tämän vuoksi kyseinen toiminnallisuus on rikottu omaksi funktioksi ja joudumme käyttämään aiemmin määrättyä skipTo -muuttujaa.

/**
* Handle list items
* @param {string[]} blocks Original text
* @param {number} currentIndex Current index
* @return {BlockInstance} Block instance
*/
function handleListItems(
   blocks: string[],
   currentIndex: number
): BlockInstance<{
   content: string;
}> {
   /**
    * Flag to keep track of the previous item
    * - If the previous item was a list item, we can continue adding list items
    * - Otherwise, we should stop adding list items and return the block
    * @type {string | null}
    */
   let previousItem: string | null = null;


   /**
    * Temporary list items array to store the list items
    * @type {BlockInstance[]}
    */
   const listItems: BlockInstance[] = [];


   /**
    * Loop through the blocks and add list items as long as the current item is no longer a list item
    */
   for (let i = currentIndex; i < blocks.length; i++) {
       // ChatGPT sometimes adds empty lines, skip them
       if (blocks[i] === '') {
           continue;
       }


       // If the previous item was a list item and the current item is not a list item, break the loop
       if (previousItem === 'list-item' && !blocks[i].startsWith('-')) {
           break;
       }


       // If the current item is a list item, add it to the list items array
       if (blocks[i].startsWith('-')) {
           previousItem = 'list-item';


           listItems.push(
               createBlock('core/list-item', {
                   content: handleTextFormatting(
                       blocks[i].replace('-', '').trim()
                   ),
               })
           );
       }
   }


   /**
    * Return the list block with the list items
    */
   return createBlock(
       'core/list',
       {},
       listItems.filter((item) => item !== undefined)
   );
}

Joudumme tekemään taas pari tarkistusta, että funktio toimii halutulla tavalla.

  1. Ensin valmistellaan previousItem -muuttuja sitä varten, että voimme tarkastaa mikä aiemman lohkon arvo on ollut. Tämä on erityisen tärkeä siitä, että emme käy läpi kaikkia sisällöstä löytyviä listoja, vaan vain ja ainoastaan seuraavan listan. 
  2. Seuraavana määritellään täysin vastaavat muuttujat looppia varten.
  3. Ja sitten varsinaiseen looppiin. Ensin pari guard clausea, jolla saadaan ennakoitua käyttäytymistä eli poistetaan tyhjät sisällöt sekä tarkistetaan ollaanko jo käyty listan viimeinen jäsen läpi if (previousItem === 'list-item' && !blocks[i].startsWith('-')) {}. Jos ollaan käyty viimeinen jäsen läpi, lopetetaan looppaus kokonaan ja palautetaan valmis lista-lohko
  4. Seuraavana tarkastetaan alkaako listan jäsen viivalla (-) ja jos näin on, luodaan tästä listan jäsenlohko (list-item).

Saatoit huomata, että käytössä on myös funktio handleTextFormatting. Tämä lyhkäisyydessään korvaa regexin avulla formatointiin käytetyt merkit * ja **. Funktio näyttää kutakuinkin tältä.

/**
* Handle text formatting
* @param {string} originalText Original text
* @return {string} Formatted text
*/
function handleTextFormatting(originalText: string): string {
   return originalText
       .replace(/\*\*(.*)\*\*/gim, '<strong>$1</strong>')
       .replace(/\*(.*)\*/gim, '<em>$1</em>');
}

Mitä seuraavaksi?

Tämä on käytännössä konseptitasolla oleva juttu ja mielestäni oman lohkon sisällä toimiva ChatGPT ei ole kaikista optimein ratkaisu. Käyttöliittymältään olisi parempi liittää samat toiminnallisuudet suoraan coressa olevaan paragraph lohkoon ja jopa kuvalohkoon kuvien generointia varten. Tämä on kuitenkin jonkun toisen ajan ihmettelyä, sillä nyt ei yksinkertaisesti riitä aika tämän kehittämiseen ja kokeiluun (tai paremman käyttöliittymän rakentamiseen).