From 5171f93a9343f42fab21ee5d1f2b3cce155f75bd Mon Sep 17 00:00:00 2001 From: Roberto Guagliardo Date: Fri, 5 Dec 2025 23:58:15 +0100 Subject: [PATCH] Add Google and Groq AI providers, enhance provider manager, and implement conversation and logging services - Introduced `Groq_AI_Provider_Google` and `Groq_AI_Provider_Groq` classes for handling AI interactions with Google and Groq respectively. - Enhanced `Groq_AI_Provider_Manager` to register and manage multiple AI providers. - Implemented `Groq_AI_Conversation_Manager` for managing conversation IDs and context hashes. - Added `Groq_AI_Generation_Logger` for logging AI generation events and managing log tables. - Developed `Groq_AI_Prompt_Builder` for constructing prompts and processing AI responses. - Established `Groq_AI_Settings_Manager` for managing plugin settings, including context fields and module configurations. --- .github/workflows/release.yml | 102 ++++ .gitignore | 20 + README.md | 93 +++ SitiWebUpdater.php | 178 ++++++ assets/css/admin.css | 236 ++++++++ assets/css/settings.css | 31 + assets/js/admin.js | 381 ++++++++++++ assets/js/settings.js | 194 ++++++ docker-compose.yml | 49 ++ docker/wordpress/Dockerfile | 13 + groq-ai-product-text.php | 335 +++++++++++ includes/Admin/class-groq-ai-logs-table.php | 160 +++++ includes/Admin/class-groq-ai-product-ui.php | 212 +++++++ .../Admin/class-groq-ai-settings-page.php | 569 ++++++++++++++++++ .../Contracts/interface-groq-ai-provider.php | 21 + .../Core/class-groq-ai-ajax-controller.php | 156 +++++ .../Core/class-groq-ai-service-container.php | 41 ++ ...class-groq-ai-abstract-openai-provider.php | 141 +++++ .../class-groq-ai-provider-google.php | 158 +++++ .../Providers/class-groq-ai-provider-groq.php | 36 ++ .../class-groq-ai-provider-manager.php | 24 + .../class-groq-ai-provider-openai.php | 36 ++ .../class-groq-ai-conversation-manager.php | 68 +++ .../class-groq-ai-generation-logger.php | 138 +++++ .../Prompt/class-groq-ai-prompt-builder.php | 353 +++++++++++ .../class-groq-ai-settings-manager.php | 295 +++++++++ 26 files changed, 4040 insertions(+) create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 SitiWebUpdater.php create mode 100644 assets/css/admin.css create mode 100644 assets/css/settings.css create mode 100644 assets/js/admin.js create mode 100644 assets/js/settings.js create mode 100644 docker-compose.yml create mode 100644 docker/wordpress/Dockerfile create mode 100644 groq-ai-product-text.php create mode 100644 includes/Admin/class-groq-ai-logs-table.php create mode 100644 includes/Admin/class-groq-ai-product-ui.php create mode 100644 includes/Admin/class-groq-ai-settings-page.php create mode 100644 includes/Contracts/interface-groq-ai-provider.php create mode 100644 includes/Core/class-groq-ai-ajax-controller.php create mode 100644 includes/Core/class-groq-ai-service-container.php create mode 100644 includes/Providers/class-groq-ai-abstract-openai-provider.php create mode 100644 includes/Providers/class-groq-ai-provider-google.php create mode 100644 includes/Providers/class-groq-ai-provider-groq.php create mode 100644 includes/Providers/class-groq-ai-provider-manager.php create mode 100644 includes/Providers/class-groq-ai-provider-openai.php create mode 100644 includes/Services/Conversations/class-groq-ai-conversation-manager.php create mode 100644 includes/Services/Logging/class-groq-ai-generation-logger.php create mode 100644 includes/Services/Prompt/class-groq-ai-prompt-builder.php create mode 100644 includes/Services/Settings/class-groq-ai-settings-manager.php diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4cd1d4a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,102 @@ +name: Build & Release Plugin + +on: + workflow_dispatch: + inputs: + release_notes: + description: 'Optionele release-opmerkingen' + required: false + push: + branches: + - main + paths: + - 'groq-ai-product-text.php' + - 'includes/**' + - 'assets/**' + - 'README.md' + - '.github/workflows/release.yml' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Determine plugin version + id: meta + run: | + VERSION=$(grep -E "^\s*\*\s*Version:" -m 1 groq-ai-product-text.php | sed -E 's/.*Version:\s*//') + VERSION=$(echo "$VERSION" | tr -d '\r') + if [ -z "$VERSION" ]; then + echo "::error::Kon pluginversie niet bepalen." + exit 1 + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check if tag exists + id: tagcheck + run: | + TAG="v${{ steps.meta.outputs.version }}" + if git rev-parse "$TAG" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Tag bestaat al – workflow afronden + if: steps.tagcheck.outputs.exists == 'true' + run: | + echo "Tag v${{ steps.meta.outputs.version }} bestaat al. Release wordt overgeslagen." + + - name: Build distributie-zip + if: steps.tagcheck.outputs.exists == 'false' + id: package + run: | + VERSION="${{ steps.meta.outputs.version }}" + SLUG="siti-ai-product-content-generator" + BUILD_ROOT="$RUNNER_TEMP/build" + DEST_DIR="$BUILD_ROOT/$SLUG" + mkdir -p "$DEST_DIR" + + rsync -a ./ "$DEST_DIR" \ + --exclude '.git/' \ + --exclude '.github/' \ + --exclude 'docker/' \ + --exclude 'docs/' \ + --exclude 'dist/' \ + --exclude 'docker-compose.yml' \ + --exclude 'PLAN.md' + + mkdir -p dist + ZIP_PATH="dist/${SLUG}-${VERSION}.zip" + (cd "$BUILD_ROOT" && zip -r "$GITHUB_WORKSPACE/$ZIP_PATH" "$SLUG") + + echo "asset_path=$ZIP_PATH" >> "$GITHUB_OUTPUT" + echo "asset_name=${SLUG}-${VERSION}.zip" >> "$GITHUB_OUTPUT" + + - name: Stel release-body samen + if: steps.tagcheck.outputs.exists == 'false' + id: releasebody + run: | + if [ -n "$RELEASE_NOTES" ]; then + echo "text=$RELEASE_NOTES" >> "$GITHUB_OUTPUT" + else + echo "text=Automatische release op basis van versie ${{ steps.meta.outputs.version }}." >> "$GITHUB_OUTPUT" + fi + env: + RELEASE_NOTES: ${{ github.event.inputs.release_notes }} + + - name: Maak GitHub release + if: steps.tagcheck.outputs.exists == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ steps.meta.outputs.version }} + name: Siti AI Product Teksten v${{ steps.meta.outputs.version }} + body: ${{ steps.releasebody.outputs.text }} + generate_release_notes: true + files: ${{ steps.package.outputs.asset_path }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..14a050a --- /dev/null +++ b/.gitignore @@ -0,0 +1,20 @@ +# OS cruft +.DS_Store +Thumbs.db + +# IDE / tooling +.idea/ +.vscode/ +*.code-workspace + +# Dependencies & builds +node_modules/ +vendor/ +dist/ + +# Logs / env +*.log +.env* + +# Docker artifacts +docker/wordpress/wp-content/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..70fd49a --- /dev/null +++ b/README.md @@ -0,0 +1,93 @@ +# SitiAI Product Teksten (WordPress plugin) + +Deze repository bevat de WordPress plugin waarmee productteksten via SitiAI kunnen worden gegenereerd. De plugincode leeft volledig in deze map en kan daarom veilig via git beheerd worden. + +## Plugin installeren en gebruiken + +### Systeemeisen + +- WordPress 6.4 of hoger. +- WooCommerce (de plugin controleert dit en deactiveert zichzelf als WooCommerce ontbreekt). +- Minimaal één API-sleutel voor Groq, OpenAI of Google Gemini. +- (Optioneel) Rank Math SEO wanneer je de extra SEO-velden wilt gebruiken. + +### Installatie + +1. Download de nieuwste release (`siti-ai-product-content-generator-x.y.z.zip`) vanaf de [GitHub Releases](https://github.com/SitiWeb/siti-ai-product-content-generator/releases) of gebruik het zip-bestand dat door de workflow in `dist/` wordt geplaatst. +2. Ga in WordPress naar **Plugins → Nieuwe plugin → Plugin uploaden** en upload het zipbestand. Je kunt de map ook handmatig naar `wp-content/plugins/` uploaden. +3. Activeer **SitiAI Product Teksten** en controleer dat WooCommerce actief is. + +### Configuratie + +1. Navigeer naar **Instellingen → Siti AI**. +2. Kies een AI-aanbieder, vul de bijbehorende API-sleutel in en (optioneel) klik op **Live modellen ophalen** om beschikbare modellen te laden. +3. Stel een standaard prompt en winkelcontext in zodat het AI-venster vooraf gevuld is. +4. Selecteer welke productvelden standaard als context dienen (titel, beschrijvingen, attributen, …). +5. Gebruik de knop **Ga naar modules** om bijvoorbeeld de Rank Math integratie aan of uit te zetten en de limieten aan te passen. +6. Via **Bekijk AI-logboek** zie je alle eerdere generaties inclusief foutmeldingen of token usage. + +### Productteksten genereren + +1. Open een product in WooCommerce en gebruik de meta-box **Gebruik AI** om de modal te openen. +2. Vul (of hergebruik) een prompt, kies welke contextvelden meegestuurd worden en klik op **Genereer tekst**. +3. De resultaten verschijnen per veld (titel, korte beschrijving, beschrijving en – indien geactiveerd – Rank Math velden). Gebruik **Kopieer** of **Vul … in** om velden direct over te nemen. +4. Via de geavanceerde sectie kun je contextvelden tijdelijk uitschakelen; dit heeft alleen effect voor de huidige generatie. +5. Iedere generatie wordt opgeslagen in het AI-logboek zodat je binnen WordPress kunt terugzoeken wat er is gebeurd. + +## Ontwikkelvereisten + +- Docker Desktop of Docker Engine + Docker Compose v2 + +## Ontwikkelen in de Docker omgeving + +1. Start de containers (WordPress + MariaDB + phpMyAdmin): + ```bash + docker compose up --build -d + ``` +2. Open http://localhost:8080 om de WordPress installatie te doorlopen. Gebruik `db` als host en de volgende databasegegevens: + - database: `wordpress` + - gebruiker: `wordpress` + - wachtwoord: `wordpress` +3. Activeer in het WordPress dashboard de plugin **SitiAI Product Teksten** (deze repository wordt in de container gemount naar `wp-content/plugins/siti-ai-product-content-generator`). + +### Handige commando's + +- Shell in de WordPress container om bijvoorbeeld `wp` CLI of git te draaien binnen de container: + ```bash + docker compose exec wordpress bash + ``` +- WP-CLI is al aanwezig: + ```bash + docker compose exec wordpress wp plugin list + ``` +- Bekijk de database via phpMyAdmin op http://localhost:8081 (gebruik dezelfde DB-gebruiker/WW als hierboven). +- Containers stoppen: + ```bash + docker compose down + ``` + +## Werken met git + +De pluginbestanden blijven op de host staan en worden alleen als bind-mount in de container gebruikt. Daardoor kun je git gewoon op je machine gebruiken: + +```bash +git status +git add . +git commit -m "Beschrijf je wijziging" +git push origin +``` + +Je kunt optioneel vanuit de container git gebruiken (zelfde codepad) wanneer je liever binnen Docker werkt. + +## Tips + +- De databank (`db_data`) en WordPress bestanden (`wordpress_data`) worden in Docker volumes opgeslagen zodat je data behouden blijft tussen sessies. +- Wil je helemaal opnieuw beginnen? Voer `docker compose down -v` uit om de volumes te verwijderen. + +## Releasen via GitHub Actions + +De workflow `.github/workflows/release.yml` bouwt automatisch een distributie-zip van de plugin, maakt een git-tag (`vX.Y.Z`) op basis van de versie in `groq-ai-product-text.php` en publiceert een GitHub Release met het zipbestand als asset. + +1. Werk de `Version`-header in `groq-ai-product-text.php` bij en commit de wijzigingen. +2. Push naar `main` of start handmatig de workflow **Build & Release Plugin** via **Actions → Run workflow** (optioneel met extra release notes). +3. De workflow slaat releases over wanneer een tag met dezelfde versie al bestaat. diff --git a/SitiWebUpdater.php b/SitiWebUpdater.php new file mode 100644 index 0000000..5037ca5 --- /dev/null +++ b/SitiWebUpdater.php @@ -0,0 +1,178 @@ +file = $file; + + add_action( 'admin_init', array( $this, 'set_plugin_properties' ) ); + + return $this; + } + + public function set_plugin_properties() { + $this->plugin = get_plugin_data( $this->file ); + $this->basename = plugin_basename( $this->file ); + $this->active = is_plugin_active( $this->basename ); + } + + public function set_username( $username ) { + $this->username = $username; + } + + public function set_repository( $repository ) { + $this->repository = $repository; + } + + public function authorize( $token ) { + $this->authorize_token = $token; + } + + private function get_repository_info() { + if ( is_null( $this->github_response ) ) { // Do we have a response? + $args = array(); + $request_uri = sprintf( 'https://api.github.com/repos/%s/%s/releases', $this->username, $this->repository ); // Build URI + + $args = array(); + + if( $this->authorize_token ) { // Is there an access token? + $args['headers']['Authorization'] = "bearer {$this->authorize_token}"; // Set the headers + } + + $response = json_decode( wp_remote_retrieve_body( wp_remote_get( $request_uri, $args ) ), true ); // Get JSON and parse it + + if( is_array( $response ) ) { // If it is an array + $response = current( $response ); // Get the first item + } + + $this->github_response = $response; // Set it to our property + } + } + + public function initialize() { + add_filter( 'pre_set_site_transient_update_plugins', array( $this, 'modify_transient' ), 10, 1 ); + add_filter( 'plugins_api', array( $this, 'plugin_popup' ), 10, 3); + add_filter( 'upgrader_post_install', array( $this, 'after_install' ), 10, 3 ); + + // Add Authorization Token to download_package + add_filter( 'upgrader_pre_download', + function() { + add_filter( 'http_request_args', [ $this, 'download_package' ], 15, 2 ); + return false; // upgrader_pre_download filter default return value. + } + ); + } + + public function modify_transient( $transient ) { + + if( property_exists( $transient, 'checked') ) { // Check if transient has a checked property + + if( $checked = $transient->checked ) { // Did Wordpress check for updates? + + $this->get_repository_info(); // Get the repo info + + $out_of_date = version_compare( $this->github_response['tag_name'], $checked[ $this->basename ], 'gt' ); // Check if we're out of date + + if( $out_of_date ) { + + $new_files = $this->github_response['zipball_url']; // Get the ZIP + + $slug = current( explode('/', $this->basename ) ); // Create valid slug + + $plugin = array( // setup our plugin info + 'url' => $this->plugin["PluginURI"], + 'slug' => $slug, + 'package' => $new_files, + 'new_version' => $this->github_response['tag_name'] + ); + + $transient->response[$this->basename] = (object) $plugin; // Return it in response + } + } + } + + return $transient; // Return filtered transient + } + + public function plugin_popup( $result, $action, $args ) { + + if( ! empty( $args->slug ) ) { // If there is a slug + + if( $args->slug == current( explode( '/' , $this->basename ) ) ) { // And it's our slug + + $this->get_repository_info(); // Get our repo info + + // Set it to an array + $plugin = array( + 'name' => $this->plugin["Name"], + 'slug' => $this->basename, + 'requires' => '5.1', + 'tested' => '6.4.3', + 'rating' => '100.0', + 'num_ratings' => '10823', + 'downloaded' => '14249', + 'added' => '2016-01-05', + 'version' => $this->github_response['tag_name'], + 'author' => $this->plugin["AuthorName"], + 'author_profile' => $this->plugin["AuthorURI"], + 'last_updated' => $this->github_response['published_at'], + 'homepage' => $this->plugin["PluginURI"], + 'short_description' => $this->plugin["Description"], + 'sections' => array( + 'Description' => $this->plugin["Description"], + 'Updates' => $this->github_response['body'], + ), + 'download_link' => $this->github_response['zipball_url'] + ); + + return (object) $plugin; // Return the data + } + + } + return $result; // Otherwise return default + } + + public function download_package( $args, $url ) { + + if ( null !== $args['filename'] ) { + if( $this->authorize_token ) { + $args = array_merge( $args, array( "headers" => array( "Authorization" => "token {$this->authorize_token}" ) ) ); + } + } + + remove_filter( 'http_request_args', [ $this, 'download_package' ] ); + + return $args; + } + + public function after_install( $response, $hook_extra, $result ) { + global $wp_filesystem; // Get global FS object + + $install_directory = plugin_dir_path( $this->file ); // Our plugin directory + $wp_filesystem->move( $result['destination'], $install_directory ); // Move files to the plugin dir + $result['destination'] = $install_directory; // Set the destination for the rest of the stack + + if ( $this->active ) { // If it was active + activate_plugin( $this->basename ); // Reactivate + } + + return $result; + } +} \ No newline at end of file diff --git a/assets/css/admin.css b/assets/css/admin.css new file mode 100644 index 0000000..e43e960 --- /dev/null +++ b/assets/css/admin.css @@ -0,0 +1,236 @@ +.groq-ai-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: none; + align-items: center; + justify-content: center; + z-index: 100000; +} + +.groq-ai-modal.is-open { + display: flex; +} + +.groq-ai-modal__dialog { + background: #fff; + max-width: 640px; + width: 90%; + padding: 24px; + border-radius: 6px; + box-shadow: 0 20px 40px rgba(0, 0, 0, 0.25); + position: relative; + max-height: 90vh; + overflow: hidden; +} + +.groq-ai-modal__dialog-inner { + max-height: calc(90vh - 20px); + overflow-y: auto; + padding-right: 8px; +} + +.groq-ai-modal__close { + position: absolute; + top: 12px; + right: 12px; + border: none; + background: transparent; + font-size: 24px; + cursor: pointer; +} + +.groq-ai-modal textarea { + width: 100%; + margin-top: 8px; +} + +.groq-ai-modal__context { + background: #f4f4f4; + padding: 12px; + border-radius: 4px; + margin-bottom: 12px; + font-size: 13px; +} + +.groq-ai-modal__actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.groq-ai-context-options { + margin-top: 0; + padding: 12px; + border-top: 1px solid #dcdcde; + background: #f9f9f9; +} + +.groq-ai-context-options__grid { + display: flex; + flex-direction: column; + gap: 10px; +} + +.groq-ai-context-option { + display: flex; + gap: 10px; + align-items: flex-start; +} + +.groq-ai-context-option input[type='checkbox'] { + margin-top: 4px; +} + +.groq-ai-modal__result { + margin-top: 16px; + background: #fafafa; + border: 1px solid #e5e5e5; + padding: 12px; + border-radius: 4px; + max-height: none; + overflow: visible; +} + +.groq-ai-result-grid { + display: flex; + flex-direction: column; + gap: 12px; +} + +.groq-ai-result-field { + background: #fff; + border: 1px solid #dcdcde; + border-radius: 4px; + padding: 10px; +} + +.groq-ai-result-field__header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.groq-ai-result-field__actions { + display: flex; + gap: 6px; + align-items: center; +} + +.groq-ai-result-field textarea { + margin-top: 8px; + width: 100%; +} + +.groq-ai-modal__raw { + margin-top: 16px; +} + +.groq-ai-modal__raw pre { + background: #1e1e1e; + color: #f8f8f2; + padding: 12px; + border-radius: 4px; + max-height: 160px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +.groq-ai-modal__status { + margin-top: 10px; + font-size: 13px; +} + +.groq-ai-modal__status[data-status='error'] { + color: #b32d2e; +} + +.groq-ai-modal__status[data-status='success'] { + color: #008a20; +} + +.groq-ai-modal.is-loading .groq-ai-modal__dialog::after { + content: ''; + position: absolute; + top: 12px; + left: 12px; + width: 18px; + height: 18px; + border: 2px solid #2271b1; + border-right-color: transparent; + border-radius: 50%; + animation: groq-spin 0.8s linear infinite; +} + +@keyframes groq-spin { + from { + transform: rotate(0); + } + to { + transform: rotate(360deg); + } +} + +.wrap .groq-ai-prompt-helper { + margin-top: 30px; + background: #fff; + padding: 20px; + border: 1px solid #ccd0d4; + border-radius: 6px; +} + +.groq-ai-advanced-settings { + margin-top: 16px; + border: 1px solid #dcdcde; + border-radius: 4px; + background: #fff; +} + +.groq-ai-advanced-toggle { + width: 100%; + background: none; + border: none; + padding: 10px 14px; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + font-weight: 600; + color: #1d2327; + cursor: pointer; +} + +.groq-ai-advanced-toggle__icon { + display: inline-block; + width: 12px; + height: 12px; + border-right: 2px solid currentColor; + border-bottom: 2px solid currentColor; + transform: rotate(45deg); + transition: transform 0.2s ease; +} + +.groq-ai-advanced-toggle.is-open .groq-ai-advanced-toggle__icon { + transform: rotate(-135deg); +} + +.groq-ai-apply-status { + min-width: 16px; + font-weight: 700; + font-size: 16px; + line-height: 1; + opacity: 0; + transition: opacity 0.2s ease; +} + +.groq-ai-apply-status.is-success { + color: #1b9a3c; + opacity: 1; +} + +.groq-ai-apply-status.is-error { + color: #b32d2e; + opacity: 1; +} diff --git a/assets/css/settings.css b/assets/css/settings.css new file mode 100644 index 0000000..440c472 --- /dev/null +++ b/assets/css/settings.css @@ -0,0 +1,31 @@ +.groq-ai-model-field select { + min-width: 300px; +} + +.groq-ai-model-custom { + margin-top: 10px; +} + +#groq-ai-refresh-models { + margin-top: 10px; +} + +#groq-ai-refresh-models-status { + margin-top: 6px; + font-size: 13px; +} + +#groq-ai-refresh-models-status[data-status='error'] { + color: #b32d2e; +} + +#groq-ai-refresh-models-status[data-status='success'] { + color: #008a20; +} + +.groq-ai-context-defaults label { + display: flex; + gap: 8px; + align-items: center; + margin-top: 8px; +} diff --git a/assets/js/admin.js b/assets/js/admin.js new file mode 100644 index 0000000..275c11c --- /dev/null +++ b/assets/js/admin.js @@ -0,0 +1,381 @@ +(function ($) { + const modal = document.getElementById('groq-ai-modal'); + if (!modal) { + return; + } + + const openButtons = document.querySelectorAll('.groq-ai-open-modal'); + const closeButton = modal.querySelector('.groq-ai-modal__close'); + const form = document.getElementById('groq-ai-form'); + const promptField = document.getElementById('groq-ai-prompt'); + const statusField = modal.querySelector('.groq-ai-modal__status'); + const resultWrapper = modal.querySelector('.groq-ai-modal__result'); + const resultField = document.getElementById('groq-ai-output'); + const jsonCopyButton = modal.querySelector('.groq-ai-copy-json'); + const contextToggles = modal.querySelectorAll('.groq-ai-context-toggle'); + const resultFields = {}; + modal.querySelectorAll('.groq-ai-result-field').forEach((field) => { + const key = field.getAttribute('data-field'); + if (!key) { + return; + } + resultFields[key] = { + key, + container: field, + textarea: field.querySelector('textarea'), + target: field.getAttribute('data-target-input') || '', + label: field.getAttribute('data-label') || key, + rankMathAction: field.getAttribute('data-rankmath-action') || '', + status: field.querySelector('.groq-ai-apply-status') || null, + statusTimer: null, + }; + }); + + const advancedToggle = modal.querySelector('.groq-ai-advanced-toggle'); + const advancedPanel = document.getElementById('groq-ai-advanced-panel'); + + function setAdvancedState(isOpen) { + if (!advancedToggle || !advancedPanel) { + return; + } + advancedToggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); + advancedToggle.classList.toggle('is-open', isOpen); + advancedPanel.hidden = !isOpen; + } + + if (advancedToggle && advancedPanel) { + advancedToggle.addEventListener('click', () => { + const expanded = advancedToggle.getAttribute('aria-expanded') === 'true'; + setAdvancedState(!expanded); + }); + setAdvancedState(false); + } + + function openModal() { + modal.classList.add('is-open'); + modal.setAttribute('aria-hidden', 'false'); + if (promptField && !promptField.value && GroqAIGenerator.defaultPrompt) { + promptField.value = GroqAIGenerator.defaultPrompt; + } + resetContextToggles(); + setTimeout(() => promptField.focus(), 50); + } + + function closeModal() { + modal.classList.remove('is-open'); + modal.setAttribute('aria-hidden', 'true'); + statusField.textContent = ''; + } + + openButtons.forEach((button) => { + button.addEventListener('click', openModal); + }); + + if (closeButton) { + closeButton.addEventListener('click', closeModal); + } + + modal.addEventListener('click', (event) => { + if (event.target === modal) { + closeModal(); + } + }); + + document.addEventListener('keyup', (event) => { + if (event.key === 'Escape' && modal.classList.contains('is-open')) { + closeModal(); + } + }); + + function setStatus(message, type = '') { + statusField.textContent = message; + statusField.setAttribute('data-status', type); + } + + const loadingText = window.wp && wp.i18n ? wp.i18n.__('AI is bezig met schrijven...', 'groq-ai-product-text') : 'AI is bezig met schrijven...'; + const retryText = window.wp && wp.i18n ? wp.i18n.__('Probeer het opnieuw of pas je prompt/context aan.', 'groq-ai-product-text') : 'Probeer het opnieuw of pas je prompt/context aan.'; + + function toggleLoading(isLoading) { + modal.classList.toggle('is-loading', isLoading); + if (isLoading) { + setStatus(loadingText, 'loading'); + } + } + + if (form) { + form.addEventListener('submit', (event) => { + event.preventDefault(); + const prompt = promptField.value.trim(); + + const payload = new URLSearchParams(); + payload.append('action', 'groq_ai_generate_text'); + payload.append('nonce', GroqAIGenerator.nonce); + payload.append('prompt', prompt); + payload.append('post_id', GroqAIGenerator.postId || 0); + payload.append('context_fields', JSON.stringify(collectContextSelection())); + + toggleLoading(true); + resultWrapper.hidden = true; + if (jsonCopyButton) { + jsonCopyButton.disabled = true; + } + resetFieldStatuses(); + + fetch(GroqAIGenerator.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: payload.toString(), + }) + .then((response) => response.json()) + .then((json) => { + if (!json.success) { + const errorMessage = json.data && json.data.message ? json.data.message : 'Onbekende fout'; + throw new Error(errorMessage); + } + + const fields = json.data.fields || {}; + Object.keys(resultFields).forEach((key) => { + const entry = resultFields[key]; + if (entry && entry.textarea) { + entry.textarea.value = fields[key] || ''; + } + }); + resultField.textContent = (json.data.raw || '').trim(); + resultWrapper.hidden = false; + if (jsonCopyButton) { + jsonCopyButton.disabled = false; + } + setStatus('Structuur gegenereerd. Kopieer of vul velden in.', 'success'); + }) + .catch((error) => { + const message = error && error.message ? error.message : 'Er ging iets mis bij het genereren.'; + setStatus(loadingText, 'error'); + const fullMessage = `${loadingText} ${message}. ${retryText}`; + statusField.textContent = fullMessage; + }) + .finally(() => { + toggleLoading(false); + }); + }); + } + + function copyToClipboard(text) { + if (!text) { + return Promise.reject(); + } + if (navigator.clipboard) { + return navigator.clipboard.writeText(text); + } + return new Promise((resolve, reject) => { + const temp = document.createElement('textarea'); + temp.value = text; + document.body.appendChild(temp); + temp.select(); + try { + document.execCommand('copy'); + resolve(); + } catch (err) { + reject(err); + } finally { + document.body.removeChild(temp); + } + }); + } + + function applyRankMathField(action, value) { + if (!action || !window.wp || !window.wp.data || typeof window.wp.data.dispatch !== 'function') { + return false; + } + const dispatcher = window.wp.data.dispatch('rank-math'); + if (!dispatcher || typeof dispatcher[action] !== 'function') { + return false; + } + try { + dispatcher[action](value); + return true; + } catch (error) { + if (window.console && console.warn) { + console.warn('[GroqAI] Rank Math veld kon niet worden bijgewerkt', error); + } + } + return false; + } + + function setFieldStatus(fieldKey, state) { + const entry = resultFields[fieldKey]; + if (!entry || !entry.status) { + return; + } + if (entry.statusTimer) { + clearTimeout(entry.statusTimer); + entry.statusTimer = null; + } + entry.status.textContent = ''; + entry.status.classList.remove('is-success', 'is-error'); + if (!state) { + return; + } + if (state === 'success') { + entry.status.textContent = '✓'; + entry.status.classList.add('is-success'); + } else if (state === 'error') { + entry.status.textContent = '!'; + entry.status.classList.add('is-error'); + } + entry.statusTimer = setTimeout(() => { + setFieldStatus(fieldKey, null); + }, 4000); + } + + function resetFieldStatuses() { + Object.keys(resultFields).forEach((key) => setFieldStatus(key, null)); + } + + function shouldUseTinyMCE(selector) { + return selector === '#content' || selector === '#excerpt'; + } + + function applyTinyMCEContent(selector, value) { + if (!shouldUseTinyMCE(selector) || !window.tinymce) { + return false; + } + const editorId = selector.startsWith('#') ? selector.substring(1) : selector; + const editor = window.tinymce.get(editorId); + if (!editor) { + return false; + } + try { + editor.setContent(value); + editor.save(); + return true; + } catch (error) { + if (window.console && console.warn) { + console.warn('[GroqAI] TinyMCE update mislukt voor', selector, error); + } + } + return false; + } + + function applyToTarget(fieldKey) { + const entry = resultFields[fieldKey]; + if (!entry) { + return; + } + const value = entry.textarea ? entry.textarea.value : ''; + let applied = false; + const fallbackSelectors = getFallbackSelectors(fieldKey); + + const allSelectors = []; + if (entry.target) { + allSelectors.push(entry.target); + } + Array.prototype.push.apply(allSelectors, fallbackSelectors); + + for (let i = 0; i < allSelectors.length && !applied; i += 1) { + const selector = allSelectors[i]; + + if (shouldUseTinyMCE(selector)) { + applied = applyTinyMCEContent(selector, value); + if (applied) { + break; + } + } + + const target = document.querySelector(selector); + if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA')) { + target.value = value; + target.dispatchEvent(new Event('input', { bubbles: true })); + target.dispatchEvent(new Event('change', { bubbles: true })); + applied = true; + } + + if (!applied && shouldUseTinyMCE(selector)) { + applied = applyTinyMCEContent(selector, value); + } + } + + if (!applied && entry.rankMathAction) { + applied = applyRankMathField(entry.rankMathAction, value); + } + + if (applied) { + setStatus(entry.label + ' ingevuld.', 'success'); + setFieldStatus(fieldKey, 'success'); + } else { + setStatus('Kon het veld niet automatisch invullen.', 'error'); + setFieldStatus(fieldKey, 'error'); + } + } + + function getFallbackSelectors(fieldKey) { + switch (fieldKey) { + case 'meta_title': + return ['input[name="rank_math_title"]']; + case 'meta_description': + return ['textarea[name="rank_math_description"]']; + case 'focus_keywords': + return ['input[name="rank_math_focus_keyword"]']; + default: + return []; + } + } + + modal.addEventListener('click', (event) => { + if (event.target.classList.contains('groq-ai-copy-field')) { + const field = event.target.getAttribute('data-field'); + const entry = resultFields[field]; + if (!entry || !entry.textarea) { + return; + } + copyToClipboard(entry.textarea.value) + .then(() => { + setStatus(entry.label + ' gekopieerd naar het klembord.', 'success'); + }) + .catch(() => { + setStatus('Kopiëren mislukt.', 'error'); + }); + } + + if (event.target.classList.contains('groq-ai-apply-field')) { + const field = event.target.getAttribute('data-field'); + applyToTarget(field); + } + }); + + if (jsonCopyButton) { + jsonCopyButton.addEventListener('click', () => { + const text = resultField ? resultField.textContent.trim() : ''; + copyToClipboard(text) + .then(() => { + setStatus('JSON gekopieerd naar het klembord.', 'success'); + }) + .catch(() => { + setStatus('Kopiëren mislukt.', 'error'); + }); + }); + } + + function resetContextToggles() { + const defaults = GroqAIGenerator.contextDefaults || {}; + contextToggles.forEach((toggle) => { + const key = toggle.getAttribute('data-field'); + if (!key) { + return; + } + const state = Object.prototype.hasOwnProperty.call(defaults, key) ? !!defaults[key] : true; + toggle.checked = state; + }); + } + + function collectContextSelection() { + const selected = []; + contextToggles.forEach((toggle) => { + if (toggle.checked) { + selected.push(toggle.getAttribute('data-field')); + } + }); + return selected; + } +})(jQuery); diff --git a/assets/js/settings.js b/assets/js/settings.js new file mode 100644 index 0000000..6b799a9 --- /dev/null +++ b/assets/js/settings.js @@ -0,0 +1,194 @@ +(function () { + function onReady(callback) { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', callback); + } else { + callback(); + } + } + + onReady(function () { + const data = window.GroqAISettingsData || {}; + const optionKey = data.optionKey || ''; + if (!optionKey) { + return; + } + + const providerSelect = document.querySelector('select[name="' + optionKey + '[provider]"]'); + const modelSelect = document.getElementById('groq-ai-model-select'); + const refreshButton = document.getElementById('groq-ai-refresh-models'); + const refreshStatus = document.getElementById('groq-ai-refresh-models-status'); + let currentModelValue = (modelSelect && modelSelect.dataset.currentModel) || data.currentModel || ''; + + function toggleProviderRows() { + if (!data.providerRows || !providerSelect) { + return; + } + + Object.keys(data.providerRows).forEach(function (provider) { + const rowId = data.providerRows[provider]; + let row = rowId ? document.getElementById(rowId) : null; + if (!row) { + const field = document.querySelector('[data-provider-row="' + provider + '"]'); + if (field) { + row = field.closest('tr') || field; + } + } + if (!row) { + return; + } + const target = row.tagName && row.tagName.toLowerCase() === 'tr' ? row : row.closest('tr') || row; + target.style.display = provider === providerSelect.value ? '' : 'none'; + }); + } + + function buildModelOptions() { + if (!modelSelect || !data.providers) { + return; + } + + const provider = providerSelect ? providerSelect.value : data.currentProvider; + const providerData = data.providers[provider]; + if (!providerData) { + return; + } + + const models = Array.isArray(providerData.models) ? providerData.models : []; + const frag = document.createDocumentFragment(); + const placeholder = document.createElement('option'); + placeholder.value = ''; + const defaultLabel = providerData.default_label || (data.placeholders && data.placeholders.selectModel) || 'Selecteer een model via "Live modellen ophalen"'; + placeholder.textContent = defaultLabel; + frag.appendChild(placeholder); + + let hasCurrent = false; + models.forEach(function (model) { + const option = document.createElement('option'); + option.value = model; + option.textContent = model; + if (model === currentModelValue) { + hasCurrent = true; + } + frag.appendChild(option); + }); + + if (currentModelValue && !hasCurrent) { + const extraOption = document.createElement('option'); + extraOption.value = currentModelValue; + extraOption.textContent = currentModelValue; + frag.appendChild(extraOption); + } + + modelSelect.innerHTML = ''; + modelSelect.appendChild(frag); + modelSelect.value = currentModelValue || ''; + modelSelect.dataset.currentModel = currentModelValue || ''; + } + + function setRefreshStatus(message, type) { + if (!refreshStatus) { + return; + } + refreshStatus.textContent = message || ''; + refreshStatus.dataset.status = type || ''; + } + + function updateRefreshButtonVisibility() { + if (!refreshButton) { + return; + } + const provider = providerSelect ? providerSelect.value : data.currentProvider; + const providerData = data.providers && data.providers[provider] ? data.providers[provider] : null; + const supports = providerData && providerData.supports_live; + refreshButton.style.display = supports ? '' : 'none'; + if (!supports) { + setRefreshStatus('', ''); + } + } + + function handleModelChange() { + if (!modelSelect) { + return; + } + currentModelValue = modelSelect.value; + modelSelect.dataset.currentModel = currentModelValue; + } + + function handleProviderChange() { + currentModelValue = ''; + if (modelSelect) { + modelSelect.dataset.currentModel = ''; + } + buildModelOptions(); + handleModelChange(); + toggleProviderRows(); + updateRefreshButtonVisibility(); + } + + function handleRefreshModels() { + if (!refreshButton || !data.ajaxUrl) { + return; + } + const provider = providerSelect ? providerSelect.value : data.currentProvider; + const providerData = data.providers && data.providers[provider] ? data.providers[provider] : null; + if (!providerData || !providerData.supports_live) { + setRefreshStatus('Deze aanbieder ondersteunt dit niet.', 'error'); + return; + } + + const keyField = document.querySelector('[data-provider-row="' + provider + '"] input'); + const apiKey = keyField ? keyField.value.trim() : ''; + if (!apiKey) { + setRefreshStatus('Vul eerst de API-sleutel in.', 'error'); + return; + } + + refreshButton.disabled = true; + setRefreshStatus('Modellen worden opgehaald…', 'loading'); + + const payload = new URLSearchParams(); + payload.append('action', 'groq_ai_refresh_models'); + payload.append('nonce', data.refreshNonce || ''); + payload.append('provider', provider); + payload.append('apiKey', apiKey); + + fetch(data.ajaxUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', + }, + body: payload.toString(), + }) + .then((response) => response.json()) + .then((json) => { + if (!json.success || !json.data || !Array.isArray(json.data.models)) { + throw new Error((json.data && json.data.message) || 'Onbekende fout'); + } + data.providers[provider].models = json.data.models; + buildModelOptions(); + setRefreshStatus('Modellen bijgewerkt.', 'success'); + }) + .catch((error) => { + setRefreshStatus(error.message || 'Ophalen mislukt.', 'error'); + }) + .finally(() => { + refreshButton.disabled = false; + }); + } + + if (modelSelect) { + modelSelect.addEventListener('change', handleModelChange); + } + if (providerSelect) { + providerSelect.addEventListener('change', handleProviderChange); + } + if (refreshButton) { + refreshButton.addEventListener('click', handleRefreshModels); + } + + buildModelOptions(); + handleModelChange(); + toggleProviderRows(); + updateRefreshButtonVisibility(); + }); +})(); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..f590dd9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,49 @@ +services: + db: + image: mariadb:10.11 + command: --default-authentication-plugin=mysql_native_password + restart: unless-stopped + environment: + MYSQL_DATABASE: wordpress + MYSQL_USER: wordpress + MYSQL_PASSWORD: wordpress + MYSQL_ROOT_PASSWORD: supersecret + volumes: + - db_data:/var/lib/mysql + ports: + - "3307:3306" + + wordpress: + build: + context: . + dockerfile: docker/wordpress/Dockerfile + depends_on: + - db + ports: + - "8082:80" + environment: + WORDPRESS_DB_HOST: db:3306 + WORDPRESS_DB_USER: wordpress + WORDPRESS_DB_PASSWORD: wordpress + WORDPRESS_DB_NAME: wordpress + WORDPRESS_DEBUG: 1 + WP_ENVIRONMENT_TYPE: local + volumes: + - wordpress_data:/var/www/html + - ./:/var/www/html/wp-content/plugins/siti-ai-product-content-generator + restart: unless-stopped + + phpmyadmin: + image: phpmyadmin/phpmyadmin:5 + depends_on: + - db + ports: + - "8085:80" + environment: + PMA_HOST: db + PMA_USER: wordpress + PMA_PASSWORD: wordpress + +volumes: + db_data: + wordpress_data: diff --git a/docker/wordpress/Dockerfile b/docker/wordpress/Dockerfile new file mode 100644 index 0000000..6bc4b01 --- /dev/null +++ b/docker/wordpress/Dockerfile @@ -0,0 +1,13 @@ +FROM wordpress:6.9-php8.2 + +# Install handy tooling (git, mariadb client, wp-cli) for local development. +RUN apt-get update \ + && apt-get install -y --no-install-recommends git less vim unzip mariadb-client \ + && rm -rf /var/lib/apt/lists/* \ + && curl -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \ + && chmod +x /usr/local/bin/wp + +WORKDIR /var/www/html + +# Match the default Linux UID/GID to avoid permission headaches with bind mounts. +RUN usermod -u 1000 www-data && groupmod -g 1000 www-data diff --git a/groq-ai-product-text.php b/groq-ai-product-text.php new file mode 100644 index 0000000..4117c1f --- /dev/null +++ b/groq-ai-product-text.php @@ -0,0 +1,335 @@ + 'Version', + ], + false + ); + + $groq_ai_version = isset( $groq_ai_plugin_data['Version'] ) && $groq_ai_plugin_data['Version'] ? $groq_ai_plugin_data['Version'] : '1.0.0'; + define( 'GROQ_AI_PRODUCT_TEXT_VERSION', $groq_ai_version ); +} + +if ( ! defined( 'GROQ_AI_DEBUG_TRACE_ADDED' ) && defined( 'WP_DEBUG' ) && WP_DEBUG ) { + define( 'GROQ_AI_DEBUG_TRACE_ADDED', true ); + + set_error_handler( + function ( $errno, $errstr, $errfile, $errline ) { + $target_lines = [ + '/wp-includes/functions.php:7291', + '/wp-includes/functions.php:2187', + ]; + + foreach ( $target_lines as $needle ) { + if ( false !== strpos( $errfile . ':' . $errline, $needle ) ) { + error_log( '[GroqAI Debug] ' . $errstr . ' | Stack: ' . wp_debug_backtrace_summary( null, 0, true ) ); + break; + } + } + + return false; + } + ); +} + +require_once __DIR__ . '/includes/Core/class-groq-ai-service-container.php'; +require_once __DIR__ . '/includes/Core/class-groq-ai-ajax-controller.php'; +require_once __DIR__ . '/includes/Contracts/interface-groq-ai-provider.php'; +require_once __DIR__ . '/includes/Providers/class-groq-ai-abstract-openai-provider.php'; +require_once __DIR__ . '/includes/Providers/class-groq-ai-provider-groq.php'; +require_once __DIR__ . '/includes/Providers/class-groq-ai-provider-openai.php'; +require_once __DIR__ . '/includes/Providers/class-groq-ai-provider-google.php'; +require_once __DIR__ . '/includes/Providers/class-groq-ai-provider-manager.php'; +require_once __DIR__ . '/includes/Services/Settings/class-groq-ai-settings-manager.php'; +require_once __DIR__ . '/includes/Services/Prompt/class-groq-ai-prompt-builder.php'; +require_once __DIR__ . '/includes/Services/Conversations/class-groq-ai-conversation-manager.php'; +require_once __DIR__ . '/includes/Services/Logging/class-groq-ai-generation-logger.php'; +require_once __DIR__ . '/includes/Admin/class-groq-ai-settings-page.php'; +require_once __DIR__ . '/includes/Admin/class-groq-ai-logs-table.php'; +require_once __DIR__ . '/includes/Admin/class-groq-ai-product-ui.php'; + +if( ! class_exists( 'SitiWebUpdater' ) ){ + include_once( plugin_dir_path( __FILE__ ) . 'SitiWebUpdater.php' ); +} + +$updater = new SitiWebUpdater( __FILE__ ); +$updater->set_username( 'SitiWeb' ); +$updater->set_repository( 'siti-ai-product-content-generator' ); +$updater->initialize(); + + +final class Groq_AI_Product_Text_Plugin { + const OPTION_KEY = 'groq_ai_product_text_settings'; + const CONVERSATION_OPTION_KEY = 'groq_ai_product_text_conversations'; + + private static $instance = null; + + /** @var Groq_AI_Service_Container */ + private $container; + + /** @var */ + private $settings_page; + + /** @var Groq_AI_Product_Text_Product_UI */ + private $product_ui; + + /** @var bool */ + private $missing_wc_notice = false; + + public static function instance() { + if ( null === self::$instance ) { + self::$instance = new self(); + } + + return self::$instance; + } + + private function __construct() { + $this->register_services(); + + $this->settings_page = new Groq_AI_Product_Text_Settings_Page( $this, $this->get_provider_manager() ); + $this->product_ui = new Groq_AI_Product_Text_Product_UI( $this ); + + add_action( 'plugins_loaded', [ $this, 'maybe_create_logs_table' ] ); + add_action( 'load-plugins.php', [ $this, 'maybe_deactivate_if_woocommerce_missing' ] ); + } + + private function register_services() { + $this->container = new Groq_AI_Service_Container(); + + $this->container->set( + 'provider_manager', + function () { + return new Groq_AI_Provider_Manager(); + } + ); + + $this->container->set( + 'settings_manager', + function ( Groq_AI_Service_Container $container ) { + return new Groq_AI_Settings_Manager( self::OPTION_KEY, $container->get( 'provider_manager' ) ); + } + ); + + $this->container->set( + 'prompt_builder', + function ( Groq_AI_Service_Container $container ) { + return new Groq_AI_Prompt_Builder( $container->get( 'settings_manager' ) ); + } + ); + + $this->container->set( + 'conversation_manager', + function () { + return new Groq_AI_Conversation_Manager( self::CONVERSATION_OPTION_KEY ); + } + ); + + $this->container->set( + 'generation_logger', + function () { + return new Groq_AI_Generation_Logger(); + } + ); + + $this->container->set( + 'ajax_controller', + function () { + return new Groq_AI_Ajax_Controller( $this ); + } + ); + + // Instantiate controller immediately so hooks are registered. + $this->container->get( 'ajax_controller' ); + } + + public function get_option_key() { + return self::OPTION_KEY; + } + + public function get_provider_manager() { + return $this->container->get( 'provider_manager' ); + } + + public function get_settings_manager() { + return $this->container->get( 'settings_manager' ); + } + + public function get_prompt_builder() { + return $this->container->get( 'prompt_builder' ); + } + + public function get_conversation_manager() { + return $this->container->get( 'conversation_manager' ); + } + + public function get_generation_logger() { + return $this->container->get( 'generation_logger' ); + } + + public function get_settings() { + return $this->get_settings_manager()->all(); + } + + public function sanitize_settings( $input ) { + return $this->get_settings_manager()->sanitize( $input ); + } + + public function maybe_deactivate_if_woocommerce_missing() { + if ( $this->is_woocommerce_active() ) { + return; + } + + if ( ! function_exists( 'deactivate_plugins' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + deactivate_plugins( plugin_basename( GROQ_AI_PRODUCT_TEXT_FILE ) ); + $this->missing_wc_notice = true; + + add_action( 'admin_notices', [ $this, 'render_missing_wc_notice' ] ); + } + + public function render_missing_wc_notice() { + if ( ! $this->missing_wc_notice ) { + return; + } + ?> +
+

+ +

+
+ get_settings_manager()->get_context_field_definitions(); + } + + public function get_default_modules_settings() { + return $this->get_settings_manager()->get_default_modules_settings(); + } + + public function get_default_context_fields() { + return $this->get_settings_manager()->get_default_context_fields(); + } + + public function normalize_context_fields( $fields ) { + return $this->get_settings_manager()->normalize_context_fields( $fields ); + } + + public function get_module_config( $module, $settings = null ) { + return $this->get_settings_manager()->get_module_config( $module, $settings ); + } + + public function is_module_enabled( $module, $settings = null ) { + return $this->get_settings_manager()->is_module_enabled( $module, $settings ); + } + + public function get_rankmath_focus_keyword_limit( $settings = null ) { + return $this->get_settings_manager()->get_rankmath_focus_keyword_limit( $settings ); + } + + public function get_rankmath_meta_title_pixel_limit( $settings = null ) { + return $this->get_settings_manager()->get_rankmath_meta_title_pixel_limit( $settings ); + } + + public function get_rankmath_meta_description_pixel_limit( $settings = null ) { + return $this->get_settings_manager()->get_rankmath_meta_description_pixel_limit( $settings ); + } + + public function is_response_format_compat_enabled( $settings = null ) { + return $this->get_settings_manager()->is_response_format_compat_enabled( $settings ); + } + + public function should_use_response_format( Groq_AI_Provider_Interface $provider, $settings ) { + return ! $this->is_response_format_compat_enabled( $settings ) && $provider->supports_response_format(); + } + + public function is_rankmath_active() { + if ( class_exists( 'RankMath' ) ) { + return true; + } + + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return function_exists( 'is_plugin_active' ) && is_plugin_active( 'seo-by-rank-math/rank-math.php' ); + } + + public function is_woocommerce_active() { + if ( class_exists( 'WooCommerce' ) ) { + return true; + } + + if ( ! function_exists( 'is_plugin_active' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + } + + return function_exists( 'is_plugin_active' ) && is_plugin_active( 'woocommerce/woocommerce.php' ); + } + + public function get_selected_model( Groq_AI_Provider_Interface $provider, $settings ) { + return ! empty( $settings['model'] ) ? $settings['model'] : $provider->get_default_model(); + } + + public function log_debug( $message, $context = [] ) { + $this->get_generation_logger()->log_debug( $message, $context ); + } + private function extract_content_text( $result ) { + if ( is_array( $result ) && isset( $result['content'] ) ) { + return (string) $result['content']; + } + + return (string) $result; + } + + public function maybe_create_logs_table() { + $this->get_generation_logger()->maybe_create_table(); + } + + public static function activate() { + $logger = new Groq_AI_Generation_Logger(); + $logger->create_table(); + } +} + +register_activation_hook( GROQ_AI_PRODUCT_TEXT_FILE, [ 'Groq_AI_Product_Text_Plugin', 'activate' ] ); +Groq_AI_Product_Text_Plugin::instance(); diff --git a/includes/Admin/class-groq-ai-logs-table.php b/includes/Admin/class-groq-ai-logs-table.php new file mode 100644 index 0000000..4099397 --- /dev/null +++ b/includes/Admin/class-groq-ai-logs-table.php @@ -0,0 +1,160 @@ +plugin = $plugin; + global $wpdb; + $this->table = $wpdb->prefix . 'groq_ai_generation_logs'; + $this->posts_table = $wpdb->posts; + + parent::__construct( + [ + 'singular' => 'groq_ai_log', + 'plural' => 'groq_ai_logs', + 'ajax' => false, + ] + ); + } + + public function get_columns() { + return [ + 'created_at' => __( 'Datum', 'groq-ai-product-text' ), + 'user_id' => __( 'Gebruiker', 'groq-ai-product-text' ), + 'post_title' => __( 'Product', 'groq-ai-product-text' ), + 'provider' => __( 'Provider', 'groq-ai-product-text' ), + 'model' => __( 'Model', 'groq-ai-product-text' ), + 'status' => __( 'Status', 'groq-ai-product-text' ), + 'tokens_total' => __( 'Tokens', 'groq-ai-product-text' ), + ]; + } + + protected function get_sortable_columns() { + return [ + 'created_at' => [ 'created_at', true ], + 'provider' => [ 'provider', false ], + 'model' => [ 'model', false ], + 'status' => [ 'status', false ], + ]; + } + + protected function get_default_primary_column_name() { + return 'created_at'; + } + + public function prepare_items() { + global $wpdb; + + $per_page = 20; + $current_page = $this->get_pagenum(); + $offset = ( $current_page - 1 ) * $per_page; + + $orderby = isset( $_REQUEST['orderby'] ) ? sanitize_sql_orderby( wp_unslash( $_REQUEST['orderby'] ) ) : 'created_at'; + if ( ! $orderby ) { + $orderby = 'created_at'; + } + $order = isset( $_REQUEST['order'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_REQUEST['order'] ) ) ) : 'DESC'; + $order = in_array( $order, [ 'ASC', 'DESC' ], true ) ? $order : 'DESC'; + + $search = isset( $_REQUEST['s'] ) ? wp_unslash( trim( $_REQUEST['s'] ) ) : ''; + + $where = '1=1'; + $params = []; + + if ( $search ) { + $like = '%' . $wpdb->esc_like( $search ) . '%'; + $where .= ' AND (provider LIKE %s OR model LIKE %s OR prompt LIKE %s OR response LIKE %s OR error_message LIKE %s )'; + $params = array_merge( $params, [ $like, $like, $like, $like, $like ] ); + } + + $total_query = "SELECT COUNT(*) FROM {$this->table} l LEFT JOIN {$this->posts_table} p ON p.ID = l.post_id WHERE {$where}"; + $total_items = (int) $wpdb->get_var( + $params ? $wpdb->prepare( $total_query, $params ) : $total_query + ); + + $query = "SELECT l.*, p.post_title FROM {$this->table} l LEFT JOIN {$this->posts_table} p ON p.ID = l.post_id WHERE {$where} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d"; + $params_with_limits = array_merge( $params, [ $per_page, $offset ] ); + $this->_column_headers = [ $this->get_columns(), [], $this->get_sortable_columns() ]; + + $this->items = $params + ? $wpdb->get_results( $wpdb->prepare( $query, $params_with_limits ), ARRAY_A ) + : $wpdb->get_results( $wpdb->prepare( $query, $per_page, $offset ), ARRAY_A ); + + $this->set_pagination_args( + [ + 'total_items' => $total_items, + 'per_page' => $per_page, + 'total_pages' => ceil( $total_items / $per_page ), + ] + ); + } + + protected function column_default( $item, $column_name ) { + switch ( $column_name ) { + case 'created_at': + return esc_html( mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $item['created_at'] ) ); + case 'provider': + case 'model': + case 'status': + return esc_html( $item[ $column_name ] ); + case 'tokens_total': + return isset( $item['tokens_total'] ) ? absint( $item['tokens_total'] ) : '—'; + case 'post_title': + if ( ! $item['post_id'] ) { + return '—'; + } + $title = $item['post_title'] ? $item['post_title'] : sprintf( __( 'Product #%d', 'groq-ai-product-text' ), (int) $item['post_id'] ); + $link = get_edit_post_link( $item['post_id'] ); + return $link ? sprintf( '%s', esc_url( $link ), esc_html( $title ) ) : esc_html( $title ); + case 'user_id': + if ( empty( $item['user_id'] ) ) { + return '—'; + } + $user = get_userdata( $item['user_id'] ); + return $user ? esc_html( $user->display_name ) : (int) $item['user_id']; + case 'error_message': + return $item['error_message'] ? esc_html( $item['error_message'] ) : '—'; + default: + return isset( $item[ $column_name ] ) ? esc_html( $item[ $column_name ] ) : ''; + } + } + + public function no_items() { + esc_html_e( 'Nog geen AI-logboeken gevonden.', 'groq-ai-product-text' ); + } + + protected function column_created_at( $item ) { + $date = esc_html( mysql2date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $item['created_at'] ) ); + $payload = [ + 'created_at' => $item['created_at'], + 'user' => $this->column_default( $item, 'user_id' ), + 'post_title' => $item['post_title'], + 'provider' => $item['provider'], + 'model' => $item['model'], + 'status' => $item['status'], + 'tokens_prompt' => isset( $item['tokens_prompt'] ) ? (int) $item['tokens_prompt'] : null, + 'tokens_completion' => isset( $item['tokens_completion'] ) ? (int) $item['tokens_completion'] : null, + 'tokens_total' => isset( $item['tokens_total'] ) ? (int) $item['tokens_total'] : null, + 'prompt' => $item['prompt'], + 'response' => $item['response'], + 'error_message' => $item['error_message'], + ]; + $encoded = esc_attr( wp_json_encode( $payload ) ); + return sprintf( + '%s', + $encoded, + $date + ); + } +} diff --git a/includes/Admin/class-groq-ai-product-ui.php b/includes/Admin/class-groq-ai-product-ui.php new file mode 100644 index 0000000..f436517 --- /dev/null +++ b/includes/Admin/class-groq-ai-product-ui.php @@ -0,0 +1,212 @@ +plugin = $plugin; + + add_action( 'add_meta_boxes', [ $this, 'register_meta_box' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_admin_assets' ] ); + add_action( 'admin_footer', [ $this, 'render_modal_markup' ] ); + } + + public function register_meta_box() { + add_meta_box( + 'groq-ai-generator-box', + __( 'Gebruik AI', 'groq-ai-product-text' ), + [ $this, 'render_meta_box' ], + 'product', + 'side', + 'high' + ); + } + + public function render_meta_box() { + if ( ! current_user_can( 'edit_products' ) ) { + echo '

' . esc_html__( 'Je hebt geen toestemming om deze actie uit te voeren.', 'groq-ai-product-text' ) . '

'; + return; + } + ?> +

+ +

+ +

+ post_type && in_array( $screen->base, [ 'post', 'post-new' ], true ) ) { + wp_enqueue_style( + 'groq-ai-admin', + plugins_url( 'assets/css/admin.css', GROQ_AI_PRODUCT_TEXT_FILE ), + [], + GROQ_AI_PRODUCT_TEXT_VERSION + ); + + wp_enqueue_script( + 'groq-ai-admin', + plugins_url( 'assets/js/admin.js', GROQ_AI_PRODUCT_TEXT_FILE ), + [ 'jquery' ], + GROQ_AI_PRODUCT_TEXT_VERSION, + true + ); + + global $post; + $post_id = ( $post && isset( $post->ID ) ) ? (int) $post->ID : 0; + + $settings = $this->plugin->get_settings(); + + wp_localize_script( + 'groq-ai-admin', + 'GroqAIGenerator', + [ + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'nonce' => wp_create_nonce( 'groq_ai_generate' ), + 'defaultPrompt' => $settings['default_prompt'], + 'postId' => $post_id, + 'contextDefaults' => isset( $settings['context_fields'] ) ? $settings['context_fields'] : $this->plugin->get_default_context_fields(), + ] + ); + } + } + + public function render_modal_markup() { + $screen = get_current_screen(); + if ( ! $screen || 'product' !== $screen->post_type ) { + return; + } + + $settings = $this->plugin->get_settings(); + $rankmath_enabled = $this->plugin->is_rankmath_active() && $this->plugin->is_module_enabled( 'rankmath', $settings ); + ?> + + plugin = $plugin; + $this->provider_manager = $provider_manager; + + add_action( 'admin_menu', [ $this, 'register_settings_pages' ] ); + add_action( 'admin_init', [ $this, 'register_settings' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_settings_assets' ] ); + add_action( 'admin_head', [ $this, 'hide_menu_links' ] ); + } + + public function register_settings_pages() { + add_options_page( + __( 'Siti AI Productteksten', 'groq-ai-product-text' ), + __( 'Siti AI', 'groq-ai-product-text' ), + 'manage_options', + 'groq-ai-product-text', + [ $this, 'render_settings_page' ] + ); + + add_submenu_page( + 'options-general.php', + __( 'Siti AI Modules', 'groq-ai-product-text' ), + __( 'Siti AI Modules', 'groq-ai-product-text' ), + 'manage_options', + 'groq-ai-product-text-modules', + [ $this, 'render_modules_page' ] + ); + + add_submenu_page( + 'options-general.php', + __( 'Siti AI AI-logboek', 'groq-ai-product-text' ), + __( 'Siti AI AI-logboek', 'groq-ai-product-text' ), + 'manage_options', + 'groq-ai-product-text-logs', + [ $this, 'render_logs_page' ] + ); + + } + + public function hide_menu_links() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + ?> + + plugin->get_option_key(), [ $this->plugin, 'sanitize_settings' ] ); + + add_settings_section( + 'groq_ai_product_text_general', + __( 'Algemene instellingen', 'groq-ai-product-text' ), + '__return_false', + 'groq-ai-product-text' + ); + + add_settings_field( + 'groq_ai_provider', + __( 'AI-aanbieder', 'groq-ai-product-text' ), + [ $this, 'render_provider_field' ], + 'groq-ai-product-text', + 'groq_ai_product_text_general' + ); + + add_settings_field( + 'groq_ai_model', + __( 'Model', 'groq-ai-product-text' ), + [ $this, 'render_model_field' ], + 'groq-ai-product-text', + 'groq_ai_product_text_general' + ); + + foreach ( $this->provider_manager->get_providers() as $provider ) { + add_settings_field( + 'groq_ai_api_key_' . $provider->get_key(), + sprintf( __( '%s API-sleutel', 'groq-ai-product-text' ), $provider->get_label() ), + [ $this, 'render_provider_api_key_field' ], + 'groq-ai-product-text', + 'groq_ai_product_text_general', + [ + 'provider' => $provider, + ] + ); + } + + add_settings_field( + 'groq_ai_store_context', + __( 'Winkelcontext', 'groq-ai-product-text' ), + [ $this, 'render_store_context_field' ], + 'groq-ai-product-text', + 'groq_ai_product_text_general' + ); + + add_settings_field( + 'groq_ai_default_prompt', + __( 'Standaard prompt', 'groq-ai-product-text' ), + [ $this, 'render_default_prompt_field' ], + 'groq-ai-product-text', + 'groq_ai_product_text_general' + ); + + add_settings_field( + 'groq_ai_context_fields', + __( 'Standaard productcontext', 'groq-ai-product-text' ), + [ $this, 'render_context_fields_field' ], + 'groq-ai-product-text', + 'groq_ai_product_text_general' + ); + + add_settings_field( + 'groq_ai_response_format_compat', + __( 'Response-format compatibiliteit', 'groq-ai-product-text' ), + [ $this, 'render_response_format_compat_field' ], + 'groq-ai-product-text', + 'groq_ai_product_text_general' + ); + + add_settings_section( + 'groq_ai_product_text_modules_rankmath', + __( 'Rank Math SEO', 'groq-ai-product-text' ), + '__return_false', + 'groq-ai-product-text-modules' + ); + + add_settings_field( + 'groq_ai_module_rankmath', + __( 'Rank Math SEO', 'groq-ai-product-text' ), + [ $this, 'render_rankmath_module_field' ], + 'groq-ai-product-text-modules', + 'groq_ai_product_text_modules_rankmath' + ); + } + + public function render_settings_page() { + if ( ! current_user_can( 'manage_options' ) ) { + return; + } + + $settings = $this->plugin->get_settings(); + ?> +
+

+

+ + + + + + +

+

+
+ +
+
+

+

+ +
+
+ +
+

+

+
+ +
+
+ plugin ); + $logs_table->prepare_items(); + ?> +
+

+

+
+ + search_box( __( 'Zoek logs', 'groq-ai-product-text' ), 'groq-ai-logs' ); ?> + display(); ?> +
+
+ + + + plugin->get_settings(); + $providers = $this->provider_manager->get_providers(); + ?> + +

+ plugin->get_settings(); + $current_model = $settings['model']; + $current_provider = $settings['provider']; + ?> +
+ +

+ +

+
+ plugin->get_settings(); + /** @var Groq_AI_Provider_Interface $provider */ + $provider = $args['provider']; + $field = $provider->get_option_key(); + $provider_key = $provider->get_key(); + ?> +
+ +

+ get_label() ) + ); + ?> +

+
+ plugin->get_settings(); + ?> + +

+ plugin->get_settings(); + ?> + +

+ plugin->get_settings(); + $values = isset( $settings['context_fields'] ) ? $settings['context_fields'] : $this->plugin->get_default_context_fields(); + $definitions = $this->plugin->get_context_field_definitions(); + ?> +
+ $definition ) : + $checked = ! empty( $values[ $key ] ); + ?> + + +

+ +

+ + +
+ plugin->get_settings(); + $is_enabled = ! empty( $settings['response_format_compat'] ); + ?> + +

+ +

+ plugin->get_settings(); + $defaults = $this->plugin->get_default_modules_settings(); + $modules = isset( $settings['modules'] ) ? $settings['modules'] : $defaults; + $config = isset( $modules['rankmath'] ) ? $modules['rankmath'] : ( $defaults['rankmath'] ?? [] ); + $rankmath_active = $this->plugin->is_rankmath_active(); + $enabled = $rankmath_active && ! empty( $config['enabled'] ); + $keyword_limit = isset( $config['focus_keyword_limit'] ) ? absint( $config['focus_keyword_limit'] ) : ( $defaults['rankmath']['focus_keyword_limit'] ?? 3 ); + $keyword_limit = $keyword_limit > 0 ? $keyword_limit : 3; + $title_pixels = isset( $config['meta_title_pixel_limit'] ) ? absint( $config['meta_title_pixel_limit'] ) : ( $defaults['rankmath']['meta_title_pixel_limit'] ?? 580 ); + $title_pixels = $title_pixels > 0 ? $title_pixels : 580; + $pixel_limit = isset( $config['meta_description_pixel_limit'] ) ? absint( $config['meta_description_pixel_limit'] ) : ( $defaults['rankmath']['meta_description_pixel_limit'] ?? 920 ); + $pixel_limit = $pixel_limit > 0 ? $pixel_limit : 920; + $rankmath_active = $this->plugin->is_rankmath_active(); + ?> +
+ + +

+ +

+ + + /> +

+ +

+ + + /> +

+ +

+ + + /> +

+ +

+
+ plugin->get_settings(); + $data = [ + 'optionKey' => $this->plugin->get_option_key(), + 'providers' => [], + 'currentProvider' => $current_settings['provider'], + 'currentModel' => $current_settings['model'], + 'providerRows' => [], + 'ajaxUrl' => admin_url( 'admin-ajax.php' ), + 'refreshNonce' => wp_create_nonce( 'groq_ai_refresh_models' ), + 'placeholders' => [ + 'selectModel' => __( 'Selecteer een model via "Live modellen ophalen"', 'groq-ai-product-text' ), + ], + ]; + + foreach ( $this->provider_manager->get_providers() as $provider ) { + $data['providers'][ $provider->get_key() ] = [ + 'default_label' => sprintf( __( 'Gebruik standaardmodel (%s)', 'groq-ai-product-text' ), $provider->get_default_model() ), + 'models' => [], + 'supports_live' => $provider->supports_live_models(), + ]; + $data['providerRows'][ $provider->get_key() ] = 'groq_ai_api_key_' . $provider->get_key(); + } + + wp_localize_script( 'groq-ai-settings', 'GroqAISettingsData', $data ); + } +} diff --git a/includes/Contracts/interface-groq-ai-provider.php b/includes/Contracts/interface-groq-ai-provider.php new file mode 100644 index 0000000..88a7e85 --- /dev/null +++ b/includes/Contracts/interface-groq-ai-provider.php @@ -0,0 +1,21 @@ +plugin = $plugin; + + add_action( 'wp_ajax_groq_ai_generate_text', [ $this, 'handle_generate_text' ] ); + add_action( 'wp_ajax_groq_ai_refresh_models', [ $this, 'handle_refresh_models' ] ); + } + + public function handle_generate_text() { + if ( ! current_user_can( 'edit_products' ) ) { + wp_send_json_error( [ 'message' => __( 'Je hebt geen toestemming voor deze actie.', 'groq-ai-product-text' ) ], 403 ); + } + + check_ajax_referer( 'groq_ai_generate', 'nonce' ); + + $prompt = isset( $_POST['prompt'] ) ? sanitize_textarea_field( wp_unslash( $_POST['prompt'] ) ) : ''; + $post_id = isset( $_POST['post_id'] ) ? absint( $_POST['post_id'] ) : 0; + + $settings = $this->plugin->get_settings(); + $provider_manager = $this->plugin->get_provider_manager(); + $provider_key = $settings['provider']; + $provider = $provider_manager->get_provider( $provider_key ); + + if ( ! $provider ) { + $provider = $provider_manager->get_provider( 'groq' ); + $provider_key = 'groq'; + } + + $conversation_id = $this->plugin->get_conversation_manager()->ensure_id( $provider_key, $settings['store_context'] ); + $prompt_builder = $this->plugin->get_prompt_builder(); + $system_prompt = $prompt_builder->build_system_prompt( $settings, $conversation_id ); + $model = $this->plugin->get_selected_model( $provider, $settings ); + $context_fields = $prompt_builder->parse_context_fields_from_request( isset( $_POST['context_fields'] ) ? $_POST['context_fields'] : '', $settings ); + $product_context_text = $prompt_builder->build_product_context_block( $post_id, $context_fields ); + $prompt_with_context = $prompt_builder->prepend_context_to_prompt( $prompt, $product_context_text ); + + $response_format = null; + $use_response_format = $this->plugin->should_use_response_format( $provider, $settings ); + if ( $use_response_format ) { + $response_format = $prompt_builder->get_response_format_definition( $settings ); + $final_prompt = $prompt_with_context; + } else { + $final_prompt = $prompt_builder->append_response_instructions( $prompt_with_context, $settings ); + } + + $result = $provider->generate_content( + [ + 'prompt' => $final_prompt, + 'system_prompt' => $system_prompt, + 'model' => $model, + 'settings' => $settings, + 'temperature' => 0.7, + 'conversation_id' => $conversation_id, + 'response_format' => $response_format, + ] + ); + + if ( is_wp_error( $result ) ) { + $this->plugin->get_generation_logger()->log_generation_event( + [ + 'provider' => $provider_key, + 'model' => $model, + 'prompt' => $final_prompt, + 'response' => '', + 'usage' => [], + 'post_id' => $post_id, + 'status' => 'error', + 'error_message' => $result->get_error_message(), + ] + ); + wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 ); + } + + $response_text = $this->extract_content_text( $result ); + $response_usage = is_array( $result ) && isset( $result['usage'] ) ? $result['usage'] : []; + + $response = $prompt_builder->parse_structured_response( $response_text, $settings ); + + if ( is_wp_error( $response ) ) { + $this->plugin->get_generation_logger()->log_generation_event( + [ + 'provider' => $provider_key, + 'model' => $model, + 'prompt' => $final_prompt, + 'response' => $response_text, + 'usage' => $response_usage, + 'post_id' => $post_id, + 'status' => 'error', + 'error_message' => $response->get_error_message(), + ] + ); + wp_send_json_error( [ 'message' => $response->get_error_message() ], 500 ); + } + + $this->plugin->get_generation_logger()->log_generation_event( + [ + 'provider' => $provider_key, + 'model' => $model, + 'prompt' => $final_prompt, + 'response' => $response_text, + 'usage' => $response_usage, + 'post_id' => $post_id, + 'status' => 'success', + ] + ); + + wp_send_json_success( + [ + 'fields' => $response, + 'raw' => $response_text, + ] + ); + } + + public function handle_refresh_models() { + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Geen toestemming.', 'groq-ai-product-text' ) ], 403 ); + } + + check_ajax_referer( 'groq_ai_refresh_models', 'nonce' ); + + $provider_key = isset( $_POST['provider'] ) ? sanitize_text_field( wp_unslash( $_POST['provider'] ) ) : ''; + $api_key = isset( $_POST['apiKey'] ) ? sanitize_text_field( wp_unslash( $_POST['apiKey'] ) ) : ''; + + if ( empty( $provider_key ) || empty( $api_key ) ) { + wp_send_json_error( [ 'message' => __( 'Provider en API-sleutel zijn verplicht.', 'groq-ai-product-text' ) ], 400 ); + } + + $provider = $this->plugin->get_provider_manager()->get_provider( $provider_key ); + + if ( ! $provider || ! $provider->supports_live_models() ) { + wp_send_json_error( [ 'message' => __( 'Deze aanbieder ondersteunt het ophalen van modellen niet.', 'groq-ai-product-text' ) ], 400 ); + } + + $result = $provider->fetch_live_models( $api_key ); + + if ( is_wp_error( $result ) ) { + wp_send_json_error( [ 'message' => $result->get_error_message() ], 500 ); + } + + wp_send_json_success( [ 'models' => array_values( array_unique( $result ) ) ] ); + } + + private function extract_content_text( $result ) { + if ( is_array( $result ) && isset( $result['content'] ) ) { + return (string) $result['content']; + } + + return (string) $result; + } +} diff --git a/includes/Core/class-groq-ai-service-container.php b/includes/Core/class-groq-ai-service-container.php new file mode 100644 index 0000000..1e0f9e7 --- /dev/null +++ b/includes/Core/class-groq-ai-service-container.php @@ -0,0 +1,41 @@ + */ + private $services = []; + + /** + * Registreer een service factory. + * + * @param string $key + * @param callable $factory + */ + public function set( $key, callable $factory ) { + $this->services[ $key ] = $factory; + } + + /** + * Haal een service op en initialiseer deze lazy. + * + * @param string $key + * @return mixed + */ + public function get( $key ) { + if ( ! isset( $this->services[ $key ] ) ) { + return null; + } + + if ( is_callable( $this->services[ $key ] ) ) { + $this->services[ $key ] = call_user_func( $this->services[ $key ], $this ); + } + + return $this->services[ $key ]; + } +} diff --git a/includes/Providers/class-groq-ai-abstract-openai-provider.php b/includes/Providers/class-groq-ai-abstract-openai-provider.php new file mode 100644 index 0000000..630cb89 --- /dev/null +++ b/includes/Providers/class-groq-ai-abstract-openai-provider.php @@ -0,0 +1,141 @@ +get_models_endpoint(); + if ( empty( $endpoint ) ) { + return new WP_Error( 'groq_ai_models_endpoint_missing', __( 'Geen model-endpoint beschikbaar voor deze aanbieder.', 'groq-ai-product-text' ) ); + } + + $response = wp_remote_get( + $endpoint, + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + ], + 'timeout' => 20, + ] + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $body['error']['message'] ) ) { + return new WP_Error( 'groq_ai_provider_error', (string) $body['error']['message'] ); + } + + if ( empty( $body['data'] ) || ! is_array( $body['data'] ) ) { + return new WP_Error( 'groq_ai_empty_response', __( 'Geen modeldata ontvangen.', 'groq-ai-product-text' ) ); + } + + $models = []; + foreach ( $body['data'] as $model ) { + if ( ! empty( $model['id'] ) ) { + $models[] = sanitize_text_field( $model['id'] ); + } + } + + if ( empty( $models ) ) { + return new WP_Error( 'groq_ai_empty_response', __( 'Geen modeldata ontvangen.', 'groq-ai-product-text' ) ); + } + + return $models; + } + + public function generate_content( array $args ) { + $settings = isset( $args['settings'] ) ? (array) $args['settings'] : []; + $prompt = isset( $args['prompt'] ) ? $args['prompt'] : ''; + $system_prompt = isset( $args['system_prompt'] ) ? $args['system_prompt'] : ''; + $model = ! empty( $args['model'] ) ? $args['model'] : $this->get_default_model(); + $api_key = $this->get_api_key( $settings ); + + if ( empty( $api_key ) ) { + return new WP_Error( 'groq_ai_missing_api_key', sprintf( __( 'Stel eerst de API-sleutel voor %s in.', 'groq-ai-product-text' ), $this->get_label() ) ); + } + + $messages = [ + [ + 'role' => 'system', + 'content' => $system_prompt, + ], + [ + 'role' => 'user', + 'content' => $prompt, + ], + ]; + + $request_body = [ + 'model' => $model, + 'messages' => $messages, + 'temperature' => isset( $args['temperature'] ) ? (float) $args['temperature'] : 0.7, + 'max_tokens' => 1024, + ]; + + if ( ! empty( $args['response_format'] ) ) { + $request_body['response_format'] = $args['response_format']; + } + + $response = wp_remote_post( + $this->get_endpoint(), + [ + 'headers' => [ + 'Authorization' => 'Bearer ' . $api_key, + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $request_body ), + 'timeout' => isset( $args['timeout'] ) ? (int) $args['timeout'] : 60, + ] + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $body['error']['message'] ) ) { + return new WP_Error( 'groq_ai_provider_error', (string) $body['error']['message'] ); + } + + if ( empty( $body['choices'][0]['message']['content'] ) ) { + return new WP_Error( + 'groq_ai_empty_response', + sprintf( __( 'Geen antwoord ontvangen van %s.', 'groq-ai-product-text' ), $this->get_label() ) + ); + } + + $content = trim( $body['choices'][0]['message']['content'] ); + $usage = isset( $body['usage'] ) && is_array( $body['usage'] ) ? $body['usage'] : []; + + return [ + 'content' => $content, + 'usage' => $usage, + 'raw_response' => $body, + ]; + } + + abstract protected function get_endpoint(); + + abstract protected function get_models_endpoint(); + + protected function get_api_key( $settings ) { + $field = $this->get_option_key(); + return isset( $settings[ $field ] ) ? $settings[ $field ] : ''; + } +} diff --git a/includes/Providers/class-groq-ai-provider-google.php b/includes/Providers/class-groq-ai-provider-google.php new file mode 100644 index 0000000..0744926 --- /dev/null +++ b/includes/Providers/class-groq-ai-provider-google.php @@ -0,0 +1,158 @@ + $api_key, 'pageSize' => 100 ], + 'https://generativelanguage.googleapis.com/v1beta/models' + ); + + $response = wp_remote_get( + $endpoint, + [ + 'timeout' => 20, + ] + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $body['error']['message'] ) ) { + return new WP_Error( 'groq_ai_provider_error', (string) $body['error']['message'] ); + } + + if ( empty( $body['models'] ) || ! is_array( $body['models'] ) ) { + return new WP_Error( 'groq_ai_empty_response', __( 'Geen modeldata ontvangen.', 'groq-ai-product-text' ) ); + } + + $models = []; + foreach ( $body['models'] as $model ) { + if ( ! empty( $model['name'] ) ) { + $parts = explode( '/', $model['name'] ); + $models[] = sanitize_text_field( end( $parts ) ); + } + } + + if ( empty( $models ) ) { + return new WP_Error( 'groq_ai_empty_response', __( 'Geen modeldata ontvangen.', 'groq-ai-product-text' ) ); + } + + return $models; + } + + public function generate_content( array $args ) { + $settings = isset( $args['settings'] ) ? (array) $args['settings'] : []; + $prompt = isset( $args['prompt'] ) ? $args['prompt'] : ''; + $system_prompt = isset( $args['system_prompt'] ) ? $args['system_prompt'] : ''; + $model = ! empty( $args['model'] ) ? $args['model'] : $this->get_default_model(); + $api_key = isset( $settings[ $this->get_option_key() ] ) ? $settings[ $this->get_option_key() ] : ''; + + if ( empty( $api_key ) ) { + return new WP_Error( 'groq_ai_missing_api_key', sprintf( __( 'Stel eerst de API-sleutel voor %s in.', 'groq-ai-product-text' ), $this->get_label() ) ); + } + + $endpoint = add_query_arg( + 'key', + $api_key, + sprintf( 'https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent', rawurlencode( $model ) ) + ); + + $payload = [ + 'contents' => [ + [ + 'role' => 'user', + 'parts' => [ + [ + 'text' => $system_prompt . "\n\n" . $prompt, + ], + ], + ], + ], + 'generationConfig' => [ + 'temperature' => isset( $args['temperature'] ) ? (float) $args['temperature'] : 0.7, + 'maxOutputTokens' => 1024, + ], + ]; + + $response = wp_remote_post( + $endpoint, + [ + 'headers' => [ + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode( $payload ), + 'timeout' => isset( $args['timeout'] ) ? (int) $args['timeout'] : 60, + ] + ); + + if ( is_wp_error( $response ) ) { + return $response; + } + + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( isset( $body['error']['message'] ) ) { + return new WP_Error( 'groq_ai_provider_error', (string) $body['error']['message'] ); + } + + if ( empty( $body['candidates'][0]['content']['parts'] ) ) { + return new WP_Error( + 'groq_ai_empty_response', + sprintf( __( 'Geen antwoord ontvangen van %s.', 'groq-ai-product-text' ), $this->get_label() ) + ); + } + + $parts = $body['candidates'][0]['content']['parts']; + $texts = []; + + foreach ( $parts as $part ) { + if ( isset( $part['text'] ) ) { + $texts[] = $part['text']; + } + } + + $content = trim( implode( "\n\n", array_filter( $texts ) ) ); + $usage = isset( $body['usageMetadata'] ) && is_array( $body['usageMetadata'] ) ? $body['usageMetadata'] : []; + + return [ + 'content' => $content, + 'usage' => $usage, + 'raw_response' => $body, + ]; + } +} diff --git a/includes/Providers/class-groq-ai-provider-groq.php b/includes/Providers/class-groq-ai-provider-groq.php new file mode 100644 index 0000000..83ea36b --- /dev/null +++ b/includes/Providers/class-groq-ai-provider-groq.php @@ -0,0 +1,36 @@ +register_provider( new Groq_AI_Provider_Groq() ); + $this->register_provider( new Groq_AI_Provider_OpenAI() ); + $this->register_provider( new Groq_AI_Provider_Google() ); + } + + public function register_provider( Groq_AI_Provider_Interface $provider ) { + $this->providers[ $provider->get_key() ] = $provider; + } + + public function get_providers() { + return $this->providers; + } + + public function get_provider( $key ) { + return isset( $this->providers[ $key ] ) ? $this->providers[ $key ] : null; + } +} diff --git a/includes/Providers/class-groq-ai-provider-openai.php b/includes/Providers/class-groq-ai-provider-openai.php new file mode 100644 index 0000000..a94dc93 --- /dev/null +++ b/includes/Providers/class-groq-ai-provider-openai.php @@ -0,0 +1,36 @@ +option_key = $option_key; + } + + /** + * Retourneert of creëert een conversatie-ID. + * + * @param string $provider_key + * @param string $store_context + * @return string + */ + public function ensure_id( $provider_key, $store_context ) { + $states = $this->get_states(); + $context_hash = $this->get_context_hash( $store_context ); + + if ( isset( $states[ $provider_key ]['hash'], $states[ $provider_key ]['id'] ) && $states[ $provider_key ]['hash'] === $context_hash ) { + return $states[ $provider_key ]['id']; + } + + $conversation_id = wp_generate_uuid4(); + $states[ $provider_key ] = [ + 'hash' => $context_hash, + 'id' => $conversation_id, + ]; + $this->save_states( $states ); + + return $conversation_id; + } + + /** + * @return array + */ + private function get_states() { + $states = get_option( $this->option_key, [] ); + + return is_array( $states ) ? $states : []; + } + + /** + * @param array $states + * @return void + */ + private function save_states( $states ) { + update_option( $this->option_key, $states, false ); + } + + /** + * @param string $store_context + * @return string + */ + private function get_context_hash( $store_context ) { + return md5( wp_json_encode( trim( (string) $store_context ) ) ); + } +} diff --git a/includes/Services/Logging/class-groq-ai-generation-logger.php b/includes/Services/Logging/class-groq-ai-generation-logger.php new file mode 100644 index 0000000..fc6793a --- /dev/null +++ b/includes/Services/Logging/class-groq-ai-generation-logger.php @@ -0,0 +1,138 @@ +logs_table_exists() ) { + return; + } + + global $wpdb; + $table = $this->get_logs_table_name(); + + $usage = isset( $args['usage'] ) && is_array( $args['usage'] ) ? $args['usage'] : []; + $prompt_tokens = isset( $usage['prompt_tokens'] ) ? absint( $usage['prompt_tokens'] ) : null; + $completion_tokens = isset( $usage['completion_tokens'] ) ? absint( $usage['completion_tokens'] ) : null; + $total_tokens = isset( $usage['total_tokens'] ) ? absint( $usage['total_tokens'] ) : null; + + $wpdb->insert( + $table, + [ + 'created_at' => current_time( 'mysql' ), + 'user_id' => get_current_user_id(), + 'post_id' => isset( $args['post_id'] ) ? absint( $args['post_id'] ) : 0, + 'provider' => isset( $args['provider'] ) ? sanitize_text_field( $args['provider'] ) : '', + 'model' => isset( $args['model'] ) ? sanitize_text_field( $args['model'] ) : '', + 'prompt' => isset( $args['prompt'] ) ? $args['prompt'] : '', + 'response' => isset( $args['response'] ) ? $args['response'] : '', + 'tokens_prompt' => $prompt_tokens, + 'tokens_completion' => $completion_tokens, + 'tokens_total' => $total_tokens, + 'status' => isset( $args['status'] ) ? sanitize_text_field( $args['status'] ) : 'success', + 'error_message' => isset( $args['error_message'] ) ? $args['error_message'] : '', + 'usage_json' => ! empty( $usage ) ? wp_json_encode( $usage ) : null, + ] + ); + } + + public function log_debug( $message, $context = [] ) { + if ( class_exists( 'WC_Logger' ) ) { + if ( ! $this->woo_logger ) { + $this->woo_logger = wc_get_logger(); + } + + if ( $this->woo_logger ) { + $context_string = ! empty( $context ) ? ' ' . wp_json_encode( $context ) : ''; + $this->woo_logger->debug( '[GroqAI] ' . $message . $context_string, [ 'source' => 'groq-ai-product-text' ] ); + return; + } + } + + if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { + $entry = '[GroqAI] ' . $message; + + if ( ! empty( $context ) ) { + $entry .= ' ' . wp_json_encode( $context ); + } + + error_log( $entry ); + } + } + + public function maybe_create_table() { + if ( get_option( self::OPTION_TABLE_CREATED ) ) { + $this->logs_table_exists = true; + return; + } + + $this->create_table(); + } + + public function create_table() { + global $wpdb; + + $table = $this->get_logs_table_name(); + $charset_collate = $wpdb->get_charset_collate(); + + require_once ABSPATH . 'wp-admin/includes/upgrade.php'; + + $sql = "CREATE TABLE {$table} ( + id bigint(20) unsigned NOT NULL AUTO_INCREMENT, + created_at datetime NOT NULL, + user_id bigint(20) unsigned DEFAULT NULL, + post_id bigint(20) unsigned DEFAULT NULL, + provider varchar(50) NOT NULL, + model varchar(100) NOT NULL, + prompt longtext NOT NULL, + response longtext DEFAULT NULL, + tokens_prompt int unsigned DEFAULT NULL, + tokens_completion int unsigned DEFAULT NULL, + tokens_total int unsigned DEFAULT NULL, + status varchar(20) NOT NULL, + error_message text DEFAULT NULL, + usage_json longtext DEFAULT NULL, + PRIMARY KEY (id), + KEY provider (provider), + KEY post_id (post_id) + ) {$charset_collate};"; + + dbDelta( $sql ); + + $this->logs_table_exists = true; + update_option( self::OPTION_TABLE_CREATED, 1 ); + } + + private function get_logs_table_name() { + global $wpdb; + + return $wpdb->prefix . 'groq_ai_generation_logs'; + } + + private function logs_table_exists() { + if ( null !== $this->logs_table_exists ) { + return $this->logs_table_exists; + } + + global $wpdb; + $table = $this->get_logs_table_name(); + $result = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ); + $this->logs_table_exists = ( $result === $table ); + + return $this->logs_table_exists; + } +} diff --git a/includes/Services/Prompt/class-groq-ai-prompt-builder.php b/includes/Services/Prompt/class-groq-ai-prompt-builder.php new file mode 100644 index 0000000..9e86fb0 --- /dev/null +++ b/includes/Services/Prompt/class-groq-ai-prompt-builder.php @@ -0,0 +1,353 @@ +settings_manager = $settings_manager; + } + + public function build_system_prompt( $settings, $conversation_id ) { + $context = isset( $settings['store_context'] ) ? trim( $settings['store_context'] ) : ''; + $base_instruction = __( 'Je bent een copywriter voor een WooCommerce winkel en schrijft overtuigende productbeschrijvingen.', 'groq-ai-product-text' ); + + if ( $context ) { + $base_instruction = sprintf( + __( 'Je bent een copywriter voor een WooCommerce winkel. Gebruik de volgende context indien beschikbaar: %s', 'groq-ai-product-text' ), + $context + ); + } + + return sprintf( + __( 'Conversatie-ID: %1$s. %2$s', 'groq-ai-product-text' ), + $conversation_id, + $base_instruction + ); + } + + public function append_response_instructions( $prompt, $settings ) { + $instructions = (string) ( $this->get_structured_response_instructions( $settings ) ?? '' ); + $prompt = trim( (string) $prompt ); + + if ( '' === $instructions ) { + return $prompt; + } + + if ( false !== strpos( $prompt, $instructions ) ) { + return $prompt; + } + + return $prompt . "\n\n" . $instructions; + } + + public function parse_structured_response( $raw, $settings = null ) { + if ( empty( $raw ) ) { + return new WP_Error( 'groq_ai_empty_response', __( 'Geen data ontvangen van de AI.', 'groq-ai-product-text' ) ); + } + + $clean = trim( $raw ); + + if ( preg_match( '/```(?:json)?\s*(.*?)```/is', $clean, $matches ) ) { + $clean = trim( $matches[1] ); + } + + $decoded = json_decode( $clean, true ); + + if ( ! is_array( $decoded ) ) { + return new WP_Error( 'groq_ai_parse_error', __( 'Kon de AI-respons niet als JSON lezen. Probeer het opnieuw.', 'groq-ai-product-text' ) ); + } + + $fields = [ + 'title' => trim( (string) ( $decoded['title'] ?? '' ) ), + 'short_description' => trim( (string) ( $decoded['short_description'] ?? '' ) ), + 'description' => trim( (string) ( $decoded['description'] ?? '' ) ), + ]; + + if ( $this->settings_manager->is_module_enabled( 'rankmath', $settings ) ) { + $keyword_limit = $this->settings_manager->get_rankmath_focus_keyword_limit( $settings ); + $focus_keywords = []; + $raw_keyword_set = isset( $decoded['focus_keywords'] ) ? $decoded['focus_keywords'] : []; + + if ( is_array( $raw_keyword_set ) ) { + foreach ( $raw_keyword_set as $keyword ) { + $keyword = trim( (string) $keyword ); + if ( '' !== $keyword ) { + $focus_keywords[] = $keyword; + } + } + } elseif ( is_string( $raw_keyword_set ) ) { + $parts = preg_split( '/[,\\n]+/', $raw_keyword_set ); + if ( is_array( $parts ) ) { + foreach ( $parts as $part ) { + $part = trim( (string) $part ); + if ( '' !== $part ) { + $focus_keywords[] = $part; + } + } + } + } + + $focus_keywords = array_slice( array_unique( $focus_keywords ), 0, $keyword_limit ); + + $fields['meta_title'] = $this->truncate_meta_field( (string) ( $decoded['meta_title'] ?? '' ), 60 ); + $fields['meta_description'] = $this->truncate_meta_field( (string) ( $decoded['meta_description'] ?? '' ), 160 ); + $fields['focus_keywords'] = implode( ', ', $focus_keywords ); + } + + if ( implode( '', $fields ) === '' ) { + return new WP_Error( 'groq_ai_parse_error', __( 'De AI-respons bevatte geen bruikbare velden.', 'groq-ai-product-text' ) ); + } + + return $fields; + } + + public function parse_context_fields_from_request( $raw, $settings ) { + if ( empty( $raw ) ) { + return $settings['context_fields']; + } + + $decoded = json_decode( wp_unslash( $raw ), true ); + + if ( ! is_array( $decoded ) ) { + return $settings['context_fields']; + } + + $normalized = $this->settings_manager->normalize_context_fields( $decoded ); + + if ( ! array_filter( $normalized ) ) { + return $settings['context_fields']; + } + + return $normalized; + } + + public function build_product_context_block( $post_id, $fields ) { + $post_id = absint( $post_id ); + + if ( ! $post_id ) { + return ''; + } + + $parts = []; + + if ( ! empty( $fields['title'] ) ) { + $title = get_the_title( $post_id ); + if ( $title ) { + $parts[] = sprintf( __( 'Titel: %s', 'groq-ai-product-text' ), wp_strip_all_tags( $title ) ); + } + } + + if ( ! empty( $fields['short_description'] ) ) { + $excerpt = get_post_field( 'post_excerpt', $post_id ); + if ( $excerpt ) { + $parts[] = sprintf( __( 'Korte beschrijving: %s', 'groq-ai-product-text' ), wp_strip_all_tags( $excerpt ) ); + } + } + + if ( ! empty( $fields['description'] ) ) { + $content = get_post_field( 'post_content', $post_id ); + if ( $content ) { + $parts[] = sprintf( __( 'Beschrijving: %s', 'groq-ai-product-text' ), wp_strip_all_tags( $content ) ); + } + } + + if ( ! empty( $fields['attributes'] ) ) { + $attributes = $this->get_product_attributes_text( $post_id ); + if ( $attributes ) { + $parts[] = sprintf( __( 'Attributen: %s', 'groq-ai-product-text' ), $attributes ); + } + } + + return implode( "\n\n", array_filter( $parts ) ); + } + + public function prepend_context_to_prompt( $prompt, $context ) { + $context = trim( (string) $context ); + + if ( '' === $context ) { + return $prompt; + } + + $intro = __( 'Gebruik de volgende productcontext bij het schrijven:', 'groq-ai-product-text' ); + + return $intro . "\n" . $context . "\n\n" . $prompt; + } + + public function get_response_format_definition( $settings = null ) { + $rankmath_enabled = $this->settings_manager->is_module_enabled( 'rankmath', $settings ); + $keyword_limit = $this->settings_manager->get_rankmath_focus_keyword_limit( $settings ); + $title_pixels = $this->settings_manager->get_rankmath_meta_title_pixel_limit( $settings ); + $desc_pixels = $this->settings_manager->get_rankmath_meta_description_pixel_limit( $settings ); + + $properties = [ + 'title' => [ + 'type' => 'string', + 'description' => __( 'Korte, overtuigende producttitel in het Nederlands.', 'groq-ai-product-text' ), + 'minLength' => 3, + ], + 'short_description' => [ + 'type' => 'string', + 'description' => __( "Korte HTML-beschrijving in

-tags (maximaal 2 alinea's).", 'groq-ai-product-text' ), + 'minLength' => 10, + ], + 'description' => [ + 'type' => 'string', + 'description' => __( 'Uitgebreide HTML-productbeschrijving met paragrafen en eventueel lijsten.', 'groq-ai-product-text' ), + 'minLength' => 20, + ], + ]; + + if ( $rankmath_enabled ) { + $properties['meta_title'] = [ + 'type' => 'string', + 'description' => sprintf( + /* translators: 1: maximum character count, 2: maximum pixels */ + __( 'SEO-meta title (max. %1$d tekens en %2$d pixels).', 'groq-ai-product-text' ), + 60, + $title_pixels + ), + 'maxLength' => 120, + ]; + $properties['meta_description'] = [ + 'type' => 'string', + 'description' => sprintf( + /* translators: 1: maximum character count, 2: maximum pixels */ + __( 'SEO-meta description (max. %1$d tekens en %2$d pixels).', 'groq-ai-product-text' ), + 160, + $desc_pixels + ), + 'maxLength' => 320, + ]; + $properties['focus_keywords'] = [ + 'type' => 'array', + 'description' => __( 'Lijst met korte zoekwoorden zonder hashtags of extra tekst.', 'groq-ai-product-text' ), + 'maxItems' => max( 1, $keyword_limit ), + 'items' => [ + 'type' => 'string', + 'minLength' => 1, + ], + ]; + } + + $schema = [ + 'type' => 'object', + 'properties' => $properties, + 'required' => [ 'title', 'short_description', 'description' ], + 'additionalProperties' => false, + ]; + + return [ + 'type' => 'json_schema', + 'json_schema' => [ + 'name' => 'groq_ai_product_text', + 'schema' => $schema, + ], + ]; + } + + private function get_structured_response_instructions( $settings = null ) { + $schema_parts = [ + '"title":"..."', + '"short_description":"..."', + '"description":"..."', + ]; + + $rankmath_enabled = $this->settings_manager->is_module_enabled( 'rankmath', $settings ); + if ( $rankmath_enabled ) { + $schema_parts[] = '"meta_title":"..."'; + $schema_parts[] = '"meta_description":"..."'; + $schema_parts[] = '"focus_keywords":["...","..."]'; + } + + $json_structure = '{' . implode( ',', $schema_parts ) . '}'; + + $instruction = sprintf( + /* translators: %s: JSON structure example */ + __( 'Geef ALLEEN een geldig JSON-object terug met deze structuur: %s. Gebruik dubbele aanhalingstekens, geen Markdown of extra tekst. Gebruik \\n voor regeleinden. Zorg dat zowel short_description als description nooit leeg zijn.', 'groq-ai-product-text' ), + $json_structure + ); + + if ( $rankmath_enabled ) { + $keyword_limit = $this->settings_manager->get_rankmath_focus_keyword_limit( $settings ); + $title_pixels = $this->settings_manager->get_rankmath_meta_title_pixel_limit( $settings ); + $desc_pixels = $this->settings_manager->get_rankmath_meta_description_pixel_limit( $settings ); + $instruction .= ' ' . sprintf( + /* translators: 1: focus keyword limit, 2: meta title pixel limit, 3: meta description pixel limit */ + __( 'Beperk meta_title tot maximaal 60 tekens en %2$d pixels en meta_description tot maximaal 160 tekens en %3$d pixels. Lever maximaal %1$d focuskeywords in het focus_keywords-array (korte termen zonder hashtag of extra tekst).', 'groq-ai-product-text' ), + $keyword_limit, + $title_pixels, + $desc_pixels + ); + } + + $instruction .= ' ' . __( 'Zorg dat short_description en description geldige HTML bevatten (gebruik minimaal

-tags en waar relevant lijstjes of benadrukking). Voeg geen extra tekst buiten het JSON-object toe.', 'groq-ai-product-text' ); + + return $instruction; + } + + private function truncate_meta_field( $text, $limit ) { + $text = trim( (string) $text ); + + if ( '' === $text || $limit <= 0 ) { + return ''; + } + + if ( function_exists( 'mb_strlen' ) ) { + if ( mb_strlen( $text ) <= $limit ) { + return $text; + } + + return mb_substr( $text, 0, $limit ); + } + + if ( strlen( $text ) <= $limit ) { + return $text; + } + + return substr( $text, 0, $limit ); + } + + private function get_product_attributes_text( $post_id ) { + if ( ! function_exists( 'wc_get_product' ) ) { + return ''; + } + + $product = wc_get_product( $post_id ); + + if ( ! $product ) { + return ''; + } + + $attributes = $product->get_attributes(); + + if ( empty( $attributes ) ) { + return ''; + } + + $lines = []; + + foreach ( $attributes as $attribute ) { + if ( $attribute->is_taxonomy() ) { + $terms = wc_get_product_terms( $post_id, $attribute->get_name(), [ 'fields' => 'names' ] ); + $value = implode( ', ', array_map( 'sanitize_text_field', (array) $terms ) ); + $label = wc_attribute_label( $attribute->get_name() ); + } else { + $options = $attribute->get_options(); + $value = implode( ', ', array_map( 'sanitize_text_field', (array) $options ) ); + $label = sanitize_text_field( $attribute->get_name() ); + } + + $value = trim( $value ); + + if ( '' !== $value ) { + $lines[] = sprintf( '%s: %s', $label, $value ); + } + } + + return implode( '; ', $lines ); + } +} diff --git a/includes/Services/Settings/class-groq-ai-settings-manager.php b/includes/Services/Settings/class-groq-ai-settings-manager.php new file mode 100644 index 0000000..6e1497d --- /dev/null +++ b/includes/Services/Settings/class-groq-ai-settings-manager.php @@ -0,0 +1,295 @@ +option_key = $option_key; + $this->provider_manager = $provider_manager; + } + + /** + * Geeft samengestelde instellingen terug (voormalige get_settings()). + * + * @return array + */ + public function all() { + $defaults = [ + 'provider' => 'groq', + 'model' => '', + 'store_context' => '', + 'default_prompt' => '', + 'groq_api_key' => '', + 'openai_api_key' => '', + 'google_api_key' => '', + 'context_fields' => $this->get_default_context_fields(), + 'modules' => $this->get_default_modules_settings(), + 'response_format_compat' => false, + ]; + + $settings = get_option( $this->option_key, [] ); + $settings = wp_parse_args( (array) $settings, $defaults ); + $settings['context_fields'] = $this->normalize_context_fields( isset( $settings['context_fields'] ) ? $settings['context_fields'] : [] ); + $settings['modules'] = $this->sanitize_modules_settings( isset( $settings['modules'] ) ? $settings['modules'] : [] ); + + return $settings; + } + + /** + * Sanitizelogica voor register_setting callback. + * + * @param array $input + * @return array + */ + public function sanitize( $input ) { + $base_defaults = [ + 'provider' => 'groq', + 'model' => '', + 'store_context' => '', + 'default_prompt' => '', + 'groq_api_key' => '', + 'openai_api_key' => '', + 'google_api_key' => '', + 'context_fields' => $this->get_default_context_fields(), + 'modules' => $this->get_default_modules_settings(), + 'response_format_compat' => false, + ]; + + $current_settings = $this->all(); + $defaults = wp_parse_args( $current_settings, $base_defaults ); + $raw_input = (array) $input; + $input = wp_parse_args( $raw_input, $defaults ); + $context_posted = array_key_exists( 'context_fields', $raw_input ); + $modules_posted = array_key_exists( 'modules', $raw_input ); + + $provider = sanitize_text_field( $input['provider'] ); + if ( ! $this->provider_manager->get_provider( $provider ) ) { + $provider = 'groq'; + } + + return [ + 'provider' => $provider, + 'model' => sanitize_text_field( $input['model'] ), + 'store_context' => sanitize_textarea_field( $input['store_context'] ), + 'default_prompt' => sanitize_textarea_field( $input['default_prompt'] ), + 'groq_api_key' => sanitize_text_field( $input['groq_api_key'] ), + 'openai_api_key' => sanitize_text_field( $input['openai_api_key'] ), + 'google_api_key' => sanitize_text_field( $input['google_api_key'] ), + 'response_format_compat' => ! empty( $raw_input['response_format_compat'] ), + 'context_fields' => $this->normalize_context_fields( $context_posted ? $raw_input['context_fields'] : $defaults['context_fields'] ), + 'modules' => $this->sanitize_modules_settings( + $modules_posted ? $raw_input['modules'] : [], + $defaults['modules'], + isset( $current_settings['modules'] ) ? (array) $current_settings['modules'] : $this->get_default_modules_settings(), + $modules_posted + ), + ]; + } + + public function get_context_field_definitions() { + if ( null === $this->context_field_definitions ) { + $this->context_field_definitions = [ + 'title' => [ + 'label' => __( 'Producttitel', 'groq-ai-product-text' ), + 'description' => __( 'Voeg de huidige producttitel toe als context.', 'groq-ai-product-text' ), + 'default' => true, + ], + 'short_description' => [ + 'label' => __( 'Korte beschrijving', 'groq-ai-product-text' ), + 'description' => __( 'Gebruik de bestaande korte beschrijving (indien aanwezig).', 'groq-ai-product-text' ), + 'default' => true, + ], + 'description' => [ + 'label' => __( 'Volledige beschrijving', 'groq-ai-product-text' ), + 'description' => __( 'Stuurt de huidige productbeschrijving mee als bronmateriaal.', 'groq-ai-product-text' ), + 'default' => true, + ], + 'attributes' => [ + 'label' => __( 'Attributen', 'groq-ai-product-text' ), + 'description' => __( 'Voeg gestructureerde productattributen toe (zoals kleur, maat, materiaal).', 'groq-ai-product-text' ), + 'default' => false, + ], + ]; + } + + return $this->context_field_definitions; + } + + public function get_default_context_fields() { + $definitions = $this->get_context_field_definitions(); + $defaults = []; + + foreach ( $definitions as $key => $data ) { + $defaults[ $key ] = ! empty( $data['default'] ); + } + + return $defaults; + } + + public function normalize_context_fields( $fields ) { + $definitions = $this->get_context_field_definitions(); + $normalized = []; + + foreach ( $definitions as $key => $data ) { + $normalized[ $key ] = false; + } + + if ( ! is_array( $fields ) ) { + return $normalized; + } + + foreach ( $fields as $key => $value ) { + if ( is_int( $key ) ) { + $key = sanitize_text_field( $value ); + $value = true; + } + + if ( array_key_exists( $key, $normalized ) ) { + $normalized[ $key ] = (bool) $value; + } + } + + return $normalized; + } + + public function get_default_modules_settings() { + if ( null === $this->default_modules ) { + $this->default_modules = [ + 'rankmath' => [ + 'enabled' => true, + 'focus_keyword_limit' => 3, + 'meta_title_pixel_limit' => 580, + 'meta_description_pixel_limit' => 920, + ], + ]; + } + + return $this->default_modules; + } + + public function get_module_config( $module, $settings = null ) { + if ( null === $settings ) { + $settings = $this->all(); + } + + $defaults = $this->get_default_modules_settings(); + $modules = isset( $settings['modules'] ) && is_array( $settings['modules'] ) ? $settings['modules'] : []; + $config = isset( $modules[ $module ] ) ? (array) $modules[ $module ] : []; + + return wp_parse_args( $config, isset( $defaults[ $module ] ) ? $defaults[ $module ] : [] ); + } + + public function is_module_enabled( $module, $settings = null ) { + $config = $this->get_module_config( $module, $settings ); + + return ! empty( $config['enabled'] ); + } + + public function get_rankmath_focus_keyword_limit( $settings = null ) { + $config = $this->get_module_config( 'rankmath', $settings ); + $limit = isset( $config['focus_keyword_limit'] ) ? absint( $config['focus_keyword_limit'] ) : 3; + + return max( 1, min( 10, $limit ) ); + } + + public function get_rankmath_meta_title_pixel_limit( $settings = null ) { + $config = $this->get_module_config( 'rankmath', $settings ); + $value = isset( $config['meta_title_pixel_limit'] ) ? absint( $config['meta_title_pixel_limit'] ) : 580; + + return max( 200, min( 1200, $value ) ); + } + + public function get_rankmath_meta_description_pixel_limit( $settings = null ) { + $config = $this->get_module_config( 'rankmath', $settings ); + $value = isset( $config['meta_description_pixel_limit'] ) ? absint( $config['meta_description_pixel_limit'] ) : 920; + + return max( 200, min( 2000, $value ) ); + } + + public function is_response_format_compat_enabled( $settings = null ) { + if ( null === $settings ) { + $settings = $this->all(); + } + + return ! empty( $settings['response_format_compat'] ); + } + + /** + * @param array|null $modules + * @param array|null $defaults + * @param array|null $current + * @param bool $is_posted + * @return array + */ + private function sanitize_modules_settings( $modules, $defaults = null, $current = null, $is_posted = false ) { + $module_defaults = $this->get_default_modules_settings(); + + if ( ! is_array( $defaults ) ) { + $defaults = $module_defaults; + } + + if ( ! is_array( $current ) ) { + $current = $defaults; + } + + $current = wp_parse_args( $current, $defaults ); + + if ( ! is_array( $modules ) ) { + $modules = []; + } + + if ( ! $is_posted ) { + $clean = []; + foreach ( $module_defaults as $module_key => $module_default_config ) { + $raw = isset( $modules[ $module_key ] ) ? (array) $modules[ $module_key ] : []; + $clean[ $module_key ] = wp_parse_args( $raw, isset( $current[ $module_key ] ) ? $current[ $module_key ] : $module_default_config ); + } + + return $clean; + } + + $result = $current; + + foreach ( $module_defaults as $module_key => $module_default_config ) { + $raw = isset( $modules[ $module_key ] ) ? (array) $modules[ $module_key ] : []; + $current_config = isset( $current[ $module_key ] ) ? (array) $current[ $module_key ] : $module_default_config; + + $result[ $module_key ]['enabled'] = isset( $raw['enabled'] ) ? (bool) $raw['enabled'] : ( isset( $current_config['enabled'] ) ? (bool) $current_config['enabled'] : false ); + + if ( 'rankmath' === $module_key ) { + $limit = isset( $raw['focus_keyword_limit'] ) ? absint( $raw['focus_keyword_limit'] ) : ( isset( $current_config['focus_keyword_limit'] ) ? absint( $current_config['focus_keyword_limit'] ) : $module_default_config['focus_keyword_limit'] ); + if ( $limit <= 0 ) { + $limit = $module_default_config['focus_keyword_limit']; + } + $result[ $module_key ]['focus_keyword_limit'] = max( 1, min( 10, $limit ) ); + + $title_pixel_limit = isset( $raw['meta_title_pixel_limit'] ) ? absint( $raw['meta_title_pixel_limit'] ) : ( isset( $current_config['meta_title_pixel_limit'] ) ? absint( $current_config['meta_title_pixel_limit'] ) : $module_default_config['meta_title_pixel_limit'] ); + if ( $title_pixel_limit <= 0 ) { + $title_pixel_limit = $module_default_config['meta_title_pixel_limit']; + } + $result[ $module_key ]['meta_title_pixel_limit'] = max( 200, min( 1200, $title_pixel_limit ) ); + + $pixel_limit = isset( $raw['meta_description_pixel_limit'] ) ? absint( $raw['meta_description_pixel_limit'] ) : ( isset( $current_config['meta_description_pixel_limit'] ) ? absint( $current_config['meta_description_pixel_limit'] ) : $module_default_config['meta_description_pixel_limit'] ); + if ( $pixel_limit <= 0 ) { + $pixel_limit = $module_default_config['meta_description_pixel_limit']; + } + $result[ $module_key ]['meta_description_pixel_limit'] = max( 200, min( 2000, $pixel_limit ) ); + } + } + + return $result; + } +}