From 99e4a83ed5dcc6cf0cf8a5e7fa3f0e7861eaecc0 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 28 Jun 2026 11:21:56 -0500 Subject: [PATCH] feat: add AI caption generation with Claude and OpenAI support (#161) - AiGeneratorHelper: Claude Messages API and OpenAI Chat Completions with structured JSON output for social/short/chat/email_subject - AiController: AJAX endpoint with CSRF and ACL checks - config.xml: new AI fieldset (provider, API key, model, tone) - Content plugin: "Generate with AI" button in Share Content panel - Language strings for all AI config and UI elements Authored-by: Moko Consulting --- CHANGELOG.md | 3 + source/packages/com_mokosuitecross/config.xml | 39 ++++ .../language/en-GB/com_mokosuitecross.ini | 23 ++ .../src/Controller/AiController.php | 100 +++++++++ .../src/Helper/AiGeneratorHelper.php | 196 ++++++++++++++++++ .../src/Extension/MokoSuiteCrossContent.php | 47 ++++- 6 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 source/packages/com_mokosuitecross/src/Controller/AiController.php create mode 100644 source/packages/com_mokosuitecross/src/Helper/AiGeneratorHelper.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 73d0b4d3..f04b25fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] ### Added +- **AI caption generation**: Generate platform-optimized cross-post captions from article content using Claude or OpenAI (#161) +- **AI provider config**: New "AI Caption Generation" fieldset in component options with provider, API key, model, and tone settings +- **AI Generate button**: One-click AI generation button in the Share Content panel that fills all caption fields - **X/Twitter threads**: Auto-split messages exceeding 280 chars into reply chains at sentence boundaries - **X/Twitter cost-optimized posting**: Optional mode to post text-only tweet first ($0.015) with URL as separate reply ($0.20) - **X/Twitter cost warning**: Language string documenting X API pricing for text vs URL posts diff --git a/source/packages/com_mokosuitecross/config.xml b/source/packages/com_mokosuitecross/config.xml index b20dd5b1..2df31bfe 100644 --- a/source/packages/com_mokosuitecross/config.xml +++ b/source/packages/com_mokosuitecross/config.xml @@ -227,6 +227,45 @@ /> +
+ + + + + + + + + + + + +
+
+ * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Administrator\Controller; + +defined('_JEXEC') or die; + +use Joomla\CMS\Factory; +use Joomla\CMS\MVC\Controller\BaseController; +use Joomla\CMS\Session\Session; +use Joomla\Component\MokoSuiteCross\Administrator\Helper\AiGeneratorHelper; + +class AiController extends BaseController +{ + public function generate(): void + { + if (!Session::checkToken('get')) { + echo json_encode(['success' => false, 'error' => 'Invalid token']); + $this->app->close(); + + return; + } + + $user = $this->app->getIdentity(); + + if (!$user->authorise('core.edit', 'com_mokosuitecross')) { + echo json_encode(['success' => false, 'error' => 'Permission denied']); + $this->app->close(); + + return; + } + + $articleId = $this->input->getInt('article_id', 0); + + if ($articleId < 1) { + echo json_encode(['success' => false, 'error' => 'Missing article ID']); + $this->app->close(); + + return; + } + + $db = Factory::getDbo(); + $query = $db->getQuery(true) + ->select($db->quoteName(['id', 'title', 'introtext', 'catid'])) + ->from($db->quoteName('#__content')) + ->where($db->quoteName('id') . ' = ' . $articleId); + $db->setQuery($query); + $article = $db->loadObject(); + + if (!$article) { + echo json_encode(['success' => false, 'error' => 'Article not found']); + $this->app->close(); + + return; + } + + $category = ''; + $catQuery = $db->getQuery(true) + ->select($db->quoteName('title')) + ->from($db->quoteName('#__categories')) + ->where($db->quoteName('id') . ' = ' . (int) $article->catid); + $db->setQuery($catQuery); + $category = $db->loadResult() ?: ''; + + $tagQuery = $db->getQuery(true) + ->select($db->quoteName('t.title')) + ->from($db->quoteName('#__tags', 't')) + ->join('INNER', $db->quoteName('#__contentitem_tag_map', 'm') . ' ON ' . $db->quoteName('m.tag_id') . ' = ' . $db->quoteName('t.id')) + ->where($db->quoteName('m.content_item_id') . ' = ' . $articleId) + ->where($db->quoteName('m.type_alias') . ' = ' . $db->quote('com_content.article')); + $db->setQuery($tagQuery); + $tags = $db->loadColumn() ?: []; + + $introtext = strip_tags($article->introtext ?? ''); + $introtext = mb_substr($introtext, 0, 500); + + $params = \Joomla\CMS\Component\ComponentHelper::getParams('com_mokosuitecross'); + + $config = [ + 'ai_provider' => $params->get('ai_provider', 'none'), + 'ai_api_key' => $params->get('ai_api_key', ''), + 'ai_model' => $params->get('ai_model', ''), + 'ai_tone' => $params->get('ai_tone', 'professional'), + ]; + + $result = AiGeneratorHelper::generate($article->title, $introtext, $category, $tags, $config); + + $this->app->setHeader('Content-Type', 'application/json; charset=utf-8'); + echo json_encode($result); + $this->app->close(); + } +} diff --git a/source/packages/com_mokosuitecross/src/Helper/AiGeneratorHelper.php b/source/packages/com_mokosuitecross/src/Helper/AiGeneratorHelper.php new file mode 100644 index 00000000..c5201d51 --- /dev/null +++ b/source/packages/com_mokosuitecross/src/Helper/AiGeneratorHelper.php @@ -0,0 +1,196 @@ + + * @copyright Copyright (C) 2026 Moko Consulting. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +namespace Joomla\Component\MokoSuiteCross\Administrator\Helper; + +defined('_JEXEC') or die; + +class AiGeneratorHelper +{ + public static function generate(string $title, string $introtext, string $category, array $tags, array $config): array + { + $provider = $config['ai_provider'] ?? 'none'; + $apiKey = $config['ai_api_key'] ?? ''; + $model = $config['ai_model'] ?? ''; + $tone = $config['ai_tone'] ?? 'professional'; + + if ($provider === 'none' || $apiKey === '') { + return ['success' => false, 'error' => 'AI provider not configured or API key missing.']; + } + + $prompt = self::buildPrompt($title, $introtext, $category, $tags, $tone); + + $response = match ($provider) { + 'claude' => self::callClaude($prompt, $apiKey, $model ?: 'claude-haiku-4-5'), + 'openai' => self::callOpenAI($prompt, $apiKey, $model ?: 'gpt-4o-mini'), + default => '', + }; + + if ($response === '') { + return ['success' => false, 'error' => 'AI provider returned an empty response.']; + } + + $parsed = self::parseResponse($response); + + if ($parsed === null) { + return ['success' => false, 'error' => 'Could not parse AI response as JSON.']; + } + + return ['success' => true, 'data' => $parsed]; + } + + private static function callClaude(string $prompt, string $apiKey, string $model): string + { + $payload = json_encode([ + 'model' => $model, + 'max_tokens' => 500, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ]); + + $ch = curl_init('https://api.anthropic.com/v1/messages'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'x-api-key: ' . $apiKey, + 'anthropic-version: 2023-06-01', + ], + ]); + + $response = curl_exec($ch); + + if ($response === false) { + curl_close($ch); + return ''; + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300) { + return ''; + } + + $data = json_decode($response, true); + + return $data['content'][0]['text'] ?? ''; + } + + private static function callOpenAI(string $prompt, string $apiKey, string $model): string + { + $payload = json_encode([ + 'model' => $model, + 'max_tokens' => 500, + 'messages' => [ + ['role' => 'user', 'content' => $prompt], + ], + ]); + + $ch = curl_init('https://api.openai.com/v1/chat/completions'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $apiKey, + ], + ]); + + $response = curl_exec($ch); + + if ($response === false) { + curl_close($ch); + return ''; + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($httpCode < 200 || $httpCode >= 300) { + return ''; + } + + $data = json_decode($response, true); + + return $data['choices'][0]['message']['content'] ?? ''; + } + + private static function buildPrompt(string $title, string $introtext, string $category, array $tags, string $tone): string + { + $tagList = !empty($tags) ? implode(', ', $tags) : 'none'; + + $toneGuide = match ($tone) { + 'casual' => 'Use a relaxed, conversational tone.', + 'friendly' => 'Use a warm, approachable tone with enthusiasm.', + default => 'Use a professional, polished tone.', + }; + + return << mb_substr($data['social'], 0, 500), + 'short' => mb_substr($data['short'], 0, 280), + 'chat' => mb_substr($data['chat'], 0, 500), + 'email_subject' => mb_substr($data['email_subject'], 0, 120), + ]; + } +} diff --git a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php index 376b4003..57d8eb15 100644 --- a/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php +++ b/source/packages/plg_content_mokosuitecross/src/Extension/MokoSuiteCrossContent.php @@ -212,8 +212,53 @@ XML; $form->load($xml); - // Cross-post history panel for existing articles + // AI Generate button for the Share Content panel $articleId = Factory::getApplication()->input->getInt('id', 0); + $aiParams = ComponentHelper::getParams('com_mokosuitecross'); + $aiEnabled = \in_array($aiParams->get('ai_provider', 'none'), ['claude', 'openai'], true); + + if ($aiEnabled && $articleId > 0) { + $aiToken = Session::getFormToken(); + $aiUrl = Uri::base() . 'index.php?option=com_mokosuitecross&task=ai.generate&format=raw&article_id=' . $articleId . '&' . $aiToken . '=1'; + + $aiButtonHtml = '
' + . '' + . '' + . '
' + . ''; + + $aiXml = ' +
+ +
'; + $form->load($aiXml); + $form->setFieldAttribute('mokosuitecross_ai_generate', 'description', $aiButtonHtml, 'attribs'); + } + + // Cross-post history panel for existing articles if ($articleId > 0) { $query = $db->getQuery(true)