Creating AI Content in the Manager

Hey everyone.

I was working on a site which included a blog and thought it would be interesting to try to make it possible for the Content Editor to write stuff (e.g. blog posts) or improve content using generative AI - so I wrote a plugin that does that. For my implementation, I used OpenAI because that was the API key I had available, but theoretically, this is model agnostic.

Essentially, it evolved into a plugin which takes any input from a Resource, and processess it though any prompt you can come up with.

It works like this:

Customize the Manager to add an extra tab to each Resource. This is where the magic will (eventually) happen.

  1. Form Customization > Create to create a new Customization Profile
  2. Give your new Customization Profile a name e.g. AI Features
  3. In you new Profile create a new Action and select “Create & Edit Resource” from the dropdown - and give it a description e.g. AI Writer
  4. Go into and edit your your new Profile and select Regions > Create
  5. Give the new Region an id (no spaces, all lower case) e.g. modx-ai-actions and then give it a name e.g. AI Actions. This name is what will be written on the Tab in the Manager.
  6. Save

Now, when you go to any resource you should see a new tab called AI Actions.

For this example, we will create 3 actions:

Generate SEO Description: write an SEO-optimized description for our page based on the content of that page
Write a Blog Post: write a well-structured blog post based on skeleton seed content already on the page
Improve Page Content: improve the style and quality of the page content based on a target audience

So let’s start by creating the prompts we want to use to perform these actions. I set these up as chunks so that they be easily tweaked at any point.

Here is the example of the Generate SEO Description prompt:

Title: [[+title]]
Body text: [[+body]]

Based on the above title and body text, please write an SEO meta description that meets the following criteria:

Accurately Summarizes the Content: The description should provide a clear and accurate summary of the page’s content.
Includes Relevant Keywords: Naturally incorporate primary or related keywords from the content to enhance relevance for search queries.
Optimal Length: Ensure the description is between 150-160 characters for desktop and around 120 characters for mobile, to prevent truncation in search results.
Engaging and Persuasive: Write in an engaging manner that encourages users to click on the link. Use strong, action-oriented language.
Unique and Reflective of Brand Voice: The description should be unique to this page and reflect the brand’s tone and style.
Optimized for Social Sharing: Consider that the meta description might be used as a snippet when the page is shared on social media.
Here are a few examples of effective meta descriptions to inspire your response:

Example 1: “Discover actionable SEO tips and strategies to boost your website’s visibility and drive organic traffic. Learn from industry experts today!”
Example 2: “Explore the latest features of the iPhone 16 in our comprehensive review. Uncover all you need to know about Apple’s newest smartphone.”
Example 3: "Find the best travel coffee mugs with our in-depth reviews. Compare top brands on spill resistance, comfort, and more.”

We will bind the [[+title]] and [[+body]] placeholders in the next step. You can also use [[+longtitle]] if you prefer. Indeed, you can expand the plugin script to use pretty much any resource field.

Now let’s save this chunk as writeAIDescription.

You can create your own prompts for the other functions (or indeed any functions you like) and save them as chunks too. In our case, let’s call the others writeAIBlog and writeAIContent

The next step is to create the plugin itself. So create a new plugin and call it writeAI

We want the plugin to run when we open any Resource in the Manager to edit it, so in the new plugin select the System Events to OnDocFormPrerender and OnDocFormRender

Now we can start coding.

The first thing the plugin needs to do is to grab the resource fields of the page so that it populates the placeholders and can be used in the prompt. We also have to turn these into JSON as we want to send it to the OpenAI API.

 // Fetch resource fields
        $resourceId = $resource->get('id');
        $title = json_encode($resource->get('pagetitle'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $longtitle = json_encode($resource->get('longtitle'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $introtext = json_encode($resource->get('introtext'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $description = json_encode($resource->get('description'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);

        // Only take the first 2500 chars of the content to keep the prompt manageable
        $content = strip_tags($resource->getContent());
        $shortContent = substr($content, 0, 2500);
        
        $content = json_encode($content, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $shortContent = json_encode($shortContent, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        
        $pageProps = array('title' => $title, 'body' => $shortContent, 'longtitle' => $longtitle, 'description' => $description);

Then we take this content and put it through the prompts we created earlier:

 //The Chunk containing the full prompt for the SEO Description feature
        $descriptionPrompt = $modx->getChunk('writeAIDescription', array('title' => $title, 'body' => $content));
        $descriptionPrompt = strip_tags($descriptionPrompt);
        $descriptionPrompt = json_encode($descriptionPrompt);
        
        
        //The Chunk containing the full prompt for the Blog Writing feature
        $blogPrompt = $modx->getChunk('writeAIBlog', array('title' => $title, 'body' => $content));
        $blogPrompt = strip_tags($blogPrompt);
        $blogPrompt = json_encode($blogPrompt);
        
        //The Chunk containing the full prompt for the SEO Content improvement feature
        $contentPrompt = $modx->getChunk('writeAIContent', array('title' => $title, 'body' => $content));
        $contentPrompt = strip_tags($contentPrompt);
        $contentPrompt = json_encode($contentPrompt);

Great! So we now have the values from the resource fields set up and ready being passed through the prompt. Now the fun part.

The final step is to trigger action when we click the button, which will send everthing to the OpenAI API and receive and handle response. For this, you will need an API key for OpenAI.

I also added a bit of feedback about token usage to be able to see how expensive each query is.

 $modx->regClientStartupHTMLBlock('
            <script type="text/javascript">
                function triggerWriteAIAction(action) {
                    const resourceId = ' . $resourceId . ';
                    const title = ' . $title . ';
                    const longtitle = ' . $longtitle . ';
                    const introtext = ' . $introtext . ';
                    const content = ' . $content . ';
                    const shortContent = ' . $shortContent . ';
                    const description = ' . $description . ';
                    
                    const apiKey = “YOUR-API-KEY";
                    const aiModel = "gpt-4o";
                    
                    const descriptionPrompt = ' . $descriptionPrompt . ';
                    const blogPrompt = ' . $blogPrompt . ';
                    const contentPrompt = ' . $blogPrompt . ';
    
                    let prompt = "";
                    let currentText = "";
                    let role = "system";

                    switch(action) {
                        case "generate_description":
                            prompt = "Hello. " + descriptionPrompt + ". Thanks.";
                            currentText = description;
                            break;
                        case "write_blog_post":
                            prompt = "Hello. " + blogPrompt + ". Thanks.";
                            currentText = content;
                            break;
                        case "improve_content":
                            prompt = "Hello. " + contentPrompt + ". Thanks.";
                            currentText = content;
                            break;
                    }

                    const data = {
                        model: aiModel,
                        messages: [{role: role, content: prompt}],
                        max_tokens: 2000,
                        temperature: 0.7,
                        n: 1
                    };

                    const startTime = new Date().getTime();

                    fetch("https://api.openai.com/v1/chat/completions", {
                        method: "POST",
                        headers: {
                            "Content-Type": "application/json",
                            "Authorization": "Bearer " + apiKey
                        },
                        body: JSON.stringify(data)
                    })
                    .then(response => {
                        if (!response.ok) {
                            throw new Error("HTTP error " + response.status + JSON.stringify(data));
                        }
                        return response.json();
                    })
                    .then(data => {
                        const endTime = new Date().getTime();
                        const queryTime = ((endTime - startTime) / 1000).toFixed(2);

                        const resultContainer = document.getElementById("writeai-result");
                        const currentTextContainer = document.getElementById("current-text");

                        if (data.choices && data.choices.length > 0) {
                            resultContainer.innerHTML = "<h3>Generated Text:</h3><pre>" + data.choices[0].message.content.trim() + "</pre>";
                            currentTextContainer.innerHTML = "<h3>Current Text:</h3><pre>" + currentText + "</pre>";
                        } else {
                            resultContainer.innerHTML = "<h3>Error:</h3><pre>No text generated.</pre>";
                        }

                        document.getElementById("model-used").innerText = "Using " + aiModel;
                        document.getElementById("query-time").innerText = "Query time: " + queryTime + " sec";
                        document.getElementById("tokens-used").innerText = data.usage.total_tokens + " tokens used";
                    })
                    .catch(error => {
                        console.error("Error:", error);
                        const resultContainer = document.getElementById("writeai-result");
                        resultContainer.innerHTML = "<h3>Error:</h3><pre>" + error.message + "</pre>";
                    });
                }

                Ext.onReady(function() {
                    var aiActionsTab = Ext.getCmp("modx-ai-actions");
                    if (aiActionsTab) {
                        aiActionsTab.update(`
                            <div class="writeai-actions-box">
                                <h3>AI Actions for ' . $resource->get('pagetitle') . ' (' . $resourceId . ')</h3>
                                <button type="button" onclick="triggerWriteAIAction(\'generate_description\')">Generate SEO Description</button>
                                <button type="button" onclick="triggerWriteAIAction(\'write_blog_post\')">Write Blog Post</button>
                                <button type="button" onclick="triggerWriteAIAction(\'improve_content\')">Improve Content</button>
                                <div id="text-containers">
                                    <div id="writeai-result"></div>
                                    <div id="current-text"></div>
                                </div>
                                <p><small><span id="model-used"></span> /  <span id="tokens-used"></span> / <span id="query-time"></span></small></p>
                            </div>
                        `);
                    } else {
                        console.error("AI Actions tab not found.");
                    }
                });
            </script>
        ‘);

Save the Plugin and go to any resource, and your tab should now look like this:

Not bad, but we can easily add some styling to it to make it look a bit nicer. To do this, we put together a CSS file that our panel can use and include it in the script.

Create a new folder /assets/components/writeai/css/ and create a file called writeai.css in there and paste this into it.

/* WriteAI Custom Styles */
.writeai-actions-box {
    padding: 20px;
    background-color: #f3f3f3;
    border-radius: 5px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
    max-width: 100%;
    box-sizing: border-box;
}

.writeai-actions-box h3 {
    font-size: 1.5em;
    margin-bottom: 15px;
    color: #333;
}

.writeai-actions-box button {
    display: inline-block;
    margin-right: 10px;
    padding: 10px 20px;
    font-size: 1em;
    color: #fff;
    background-color: #0073aa;
    border: none;
    border-radius: 5px;
    cursor: pointer;
    transition: background-color 0.3s ease;
}

.writeai-actions-box button:hover {
    background-color: #005a8c;
}

#text-containers {
    display: flex;
    justify-content: space-between;
    margin-top: 20px;
}

#writeai-result, #current-text {
    width: 48%;
    padding: 15px;
    background-color: #fff;
    border: 1px solid #ddd;
    border-radius: 5px;
    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

#writeai-result h3, #current-text h3 {
    margin-top: 0;
    font-size: 1.2em;
    color: #333;
}

#writeai-result pre, #current-text pre {
    padding: 10px;
    background-color: #f8f8f8;
    border: 1px solid #ddd;
    border-radius: 5px;
    white-space: pre-wrap;
    word-wrap: break-word;
}

You can put as much or as little into this styling as you like. Go nuts.

Now you can include the CSS in your plugin code.

  // Include the CSS file
        $modx->regClientCSS($modx->getOption('assets_url') . 'components/writeai/css/writeai.css');

A bit better…

That’s pretty much all there is to it. In order to test that it works simply click on any of the buttons and enjoy the magic.

Here’s the full plugin code:

<?php
$eventName = $modx->event->name;

        
switch ($eventName) {
    case 'OnDocFormRender':
       
        $resource = $modx->controller->resource;
        
        // Fetch resource fields
        $resourceId = $resource->get('id');
        $title = json_encode($resource->get('pagetitle'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $longtitle = json_encode($resource->get('longtitle'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $introtext = json_encode($resource->get('introtext'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $description = json_encode($resource->get('description'), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);

        // Only take the first 2500 chars of the content to keep the prompt manageable
        $content = strip_tags($resource->getContent());
        $shortContent = substr($content, 0, 2500);
        
        $content = json_encode($content, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        $shortContent = json_encode($shortContent, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
        
       
        $pageProps = array('title' => $title, 'body' => $shortContent, 'longtitle' => $longtitle, 'description' => $description);

        
        //The Chunk containing the full prompt for the SEO Description feature
        $descriptionPrompt = $modx->getChunk('writeAIDescription', array('title' => $title, 'body' => $content));
        $descriptionPrompt = strip_tags($descriptionPrompt);
        $descriptionPrompt = json_encode($descriptionPrompt);
        
        
        //The Chunk containing the full prompt for the Blog Writing feature
        $blogPrompt = $modx->getChunk('writeAIBlog', array('title' => $title, 'body' => $content));
        $blogPrompt = strip_tags($blogPrompt);
        $blogPrompt = json_encode($blogPrompt);
        
        //The Chunk containing the full prompt for the SEO Content improvement feature
        $contentPrompt = $modx->getChunk('writeAIContent', array('title' => $title, 'body' => $content));
        $contentPrompt = strip_tags($contentPrompt);
        $contentPrompt = json_encode($contentPrompt);
        

        // Include the CSS file
        $modx->regClientCSS($modx->getOption('assets_url') . 'components/writeai/css/writeai.css');

        $modx->regClientStartupHTMLBlock('
            <script type="text/javascript">
                function triggerWriteAIAction(action) {
                    const resourceId = ' . $resourceId . ';
                    const title = ' . $title . ';
                    const longtitle = ' . $longtitle . ';
                    const introtext = ' . $introtext . ';
                    const content = ' . $content . ';
                    const shortContent = ' . $shortContent . ';
                    const description = ' . $description . ';
                    
                    const apiKey = "YOUR-API-KEY";
                    const aiModel = "gpt-4o";
                    
                    const descriptionPrompt = ' . $descriptionPrompt . ';
                    const blogPrompt = ' . $blogPrompt . ';
                    const contentPrompt = ' . $blogPrompt . ';
    
                    let prompt = "";
                    let currentText = "";
                    let role = "system";

                    switch(action) {
                        case "generate_description":
                            prompt = "Hello. " + descriptionPrompt + ". Thanks.";
                            currentText = description;
                            break;
                        case "write_blog_post":
                            prompt = "Hello. " + blogPrompt + ". Thanks.";
                            currentText = content;
                            break;
                        case "improve_content":
                            prompt = "Hello. " + contentPrompt + ". Thanks.";
                            currentText = content;
                            break;
                    }

                    const data = {
                        model: aiModel,
                        messages: [{role: role, content: prompt}],
                        max_tokens: 2000,
                        temperature: 0.7,
                        n: 1
                    };

                    const startTime = new Date().getTime();

                    fetch("https://api.openai.com/v1/chat/completions", {
                        method: "POST",
                        headers: {
                            "Content-Type": "application/json",
                            "Authorization": "Bearer " + apiKey
                        },
                        body: JSON.stringify(data)
                    })
                    .then(response => {
                        if (!response.ok) {
                            throw new Error("HTTP error " + response.status + JSON.stringify(data));
                        }
                        return response.json();
                    })
                    .then(data => {
                        const endTime = new Date().getTime();
                        const queryTime = ((endTime - startTime) / 1000).toFixed(2);

                        const resultContainer = document.getElementById("writeai-result");
                        const currentTextContainer = document.getElementById("current-text");

                        if (data.choices && data.choices.length > 0) {
                            resultContainer.innerHTML = "<h3>Generated Text:</h3><pre>" + data.choices[0].message.content.trim() + "</pre>";
                            currentTextContainer.innerHTML = "<h3>Current Text:</h3><pre>" + currentText + "</pre>";
                        } else {
                            resultContainer.innerHTML = "<h3>Error:</h3><pre>No text generated.</pre>";
                        }

                        document.getElementById("model-used").innerText = "Using " + aiModel;
                        document.getElementById("query-time").innerText = "Query time: " + queryTime + " sec";
                        document.getElementById("tokens-used").innerText = data.usage.total_tokens + " tokens used";
                    })
                    .catch(error => {
                        console.error("Error:", error);
                        const resultContainer = document.getElementById("writeai-result");
                        resultContainer.innerHTML = "<h3>Error:</h3><pre>" + error.message + "</pre>";
                    });
                }

                Ext.onReady(function() {
                    var aiActionsTab = Ext.getCmp("modx-ai-actions");
                    if (aiActionsTab) {
                        aiActionsTab.update(`
                            <div class="writeai-actions-box">
                                <h3>AI Actions for ' . $resource->get('pagetitle') . ' (' . $resourceId . ')</h3>
                                <button type="button" onclick="triggerWriteAIAction(\'generate_description\')">Generate SEO Description</button>
                                <button type="button" onclick="triggerWriteAIAction(\'write_blog_post\')">Write Blog Post</button>
                                <button type="button" onclick="triggerWriteAIAction(\'improve_content\')">Improve Content</button>
                                <div id="text-containers">
                                    <div id="writeai-result"></div>
                                    <div id="current-text"></div>
                                </div>
                                <p><small><span id="model-used"></span> /  <span id="tokens-used"></span> / <span id="query-time"></span></small></p>
                            </div>
                        `);
                    } else {
                        console.error("AI Actions tab not found.");
                    }
                });
            </script>
        ');
        break;
}

EPILOGUE

There are probably many improvements that could be made to this code in terms of efficiency and elegance and I would be very appreciative of any tips from seasoned modx developers about how it could be improved.

Some things I would like to implement in the next version:

  • Make the buttons look a bit more native to the modx manager UI
  • Create a switcher to be able to switch between using different LLMs to create the content, possibly using LangChain to do this
  • Make the UI ergonomics a bit more slick. At the moment, you have to reload the resource page even after saving of you want the plugin to use the latest content
  • Be able to use TVs in the prompt as well as resource fields.
  • Let the user define how they want the response formatted (e.g. in HTML, Markdown etc.)
  • One-click to copy the output into the resource content field

Really looking forward to hearing your feedback.

Have fun!

4 Likes

Great work! Impressive write-up as well.

1 Like

Fantastic stuff! Will test for sure.

1 Like

Really interesting. Thanks for posting this!

1 Like

For completeness, here is the (slightly modified) prompt I used to create the blog post, and the content improvement prompt chunks:

writeAIBlog

Act as an SEO copywriting expert.

Your aim is to optimise the website of a business called [[++site_name]] to drive more traffic and generate more leads.

To do this, you will write SEO optimised blog posts for the website tagetting key search terms and giving visitors useful and delightful information.

The target audiences for this content are:

  • people who might be planning to buy a property in the next 12 months
  • couples and young families who might want to move house
  • affluent people with above-average incomes
  • people with a college degree or above

Please make sure to adjust the depth of detail, focus and expertise to this level.

Some seed content has already been created in the form of rough notes to give you a direction of the search terms to optimize for.

Do not use the seed content for structure; please make sure the final post covers more than just these points and has a coherent and solid structure, including an introduction and conclusion with a subtle call to action if appropriate.

The seed content is:

Title: [[+title]]

Content: [[+body]]

Before your start writing, identify who performs best for the related search terms in Google - and if this is a potential competitor to our business.

The goal of the blog post is to provide richer and deeper content than the current best performer and to rank above them for this (and similar) search terms.

You will also take Google’s published best practices into account when writing, and explain why our content is better. Please clearly separate the explanation from the content and place it at the end.

It should start with a 1-2 sentence lead which sets up the article and grabs attention immediately.

It should take around 2-7 minutes to read.

It should be written in British English.

It should not have a standard AI-generated blog structure. The goal is to appear to the reader as natural and human as possible.

writeAIContent

Act as an SEO copywriting expert.

Your aim is to optimise the website content of a business called [[++site_name]] to drive more traffic and generate more leads.

To do this, you will analyse the current content of the page and write an improved SEO optimised version for the targetted key search terms and giving visitors useful and delightful information.

The target audiences for this content are:

  • people who might be planning to buy a property in the next 12 months
  • couples and young families who might want to move house
  • affluent people with above-average incomes
  • people with a college degree or above

Please make sure to adjust the depth of detail and expertise to this level.

Maintain the existing tone of voice and general structure of the text. Your task is to improve the quality of the existing content - not to rewrite it totally.

The current page content is:

Title: [[+title]]

Description: [[+description]]

Content: [[+body]]

Before your start writing, identify who performs best for the related search terms in Google - and if this is a potential competitor to our business.

The goal of the improved content is to provide richer and deeper content than the current best performer and to rank above them for this (and similar) search terms.

You will also take Google’s published best practices into account when writing, and explain why our content is better. Please clearly separate the explanation from the content and place it at the end.

It should be written in British English. Ensure all grammar and spelling is correct.

Your output should be in HTML and maintain the same classes as the original text to enable easy copy and paste.