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.
- Form Customization > Create to create a new Customization Profile
- Give your new Customization Profile a name e.g. AI Features
- 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
- Go into and edit your your new Profile and select Regions > Create
- 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.
- 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!