From 31f303a831a743ec00f9a3ef6ecf678837aed368 Mon Sep 17 00:00:00 2001 From: peptiderepo Date: Sat, 18 Apr 2026 02:53:40 +0700 Subject: [PATCH] feat: interactive model picker with searchable popup and pricing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace plain text inputs for analysis/writing/editor model fields with a rich model picker that fetches the OpenRouter model registry and displays a searchable popup with model name, provider, input/output pricing per 1M tokens, and context window. Also extracts source status and model field renderers into dedicated field classes to keep class-admin-page.php under the 300-line cap. New files: - assets/js/model-picker.js — popup UI with search, filtering, caching - assets/css/model-picker.css — popup styles matching admin design system - includes/admin/fields/class-open-router-model-field.php — server render - includes/admin/fields/class-source-status-field.php — extracted field Co-Authored-By: Claude Opus 4.6 --- assets/css/model-picker.css | 206 ++++++++++++++ assets/js/model-picker.js | 262 ++++++++++++++++++ includes/admin/class-admin-page.php | 70 ++--- includes/admin/class-settings-fields.php | 9 +- .../fields/class-open-router-model-field.php | 78 ++++++ .../fields/class-source-status-field.php | 66 +++++ includes/class-autoloader.php | 1 + includes/class-executor.php | 28 ++ includes/class-prautoblogger.php | 1 + 9 files changed, 670 insertions(+), 51 deletions(-) create mode 100644 assets/css/model-picker.css create mode 100644 assets/js/model-picker.js create mode 100644 includes/admin/fields/class-open-router-model-field.php create mode 100644 includes/admin/fields/class-source-status-field.php diff --git a/assets/css/model-picker.css b/assets/css/model-picker.css new file mode 100644 index 0000000..b4feb85 --- /dev/null +++ b/assets/css/model-picker.css @@ -0,0 +1,206 @@ +/** + * Model picker popup styles for PRAutoBlogger admin. + * + * Renders a modal overlay with a searchable list of AI models. + * Uses the same design tokens as admin.css (--ab-* CSS variables). + * + * @see assets/js/model-picker.js — JS that builds and manages the popup DOM. + * @see assets/css/admin.css — Parent design system variables. + */ + +/* ── Overlay ────────────────────────────────────────────────────────── */ +.ab-mp-overlay { + position: fixed; inset: 0; z-index: 100100; + background: rgba(0, 0, 0, 0.45); + display: flex; align-items: center; justify-content: center; + animation: ab-mp-fade-in 0.15s ease; +} + +@keyframes ab-mp-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +/* ── Popup container ────────────────────────────────────────────────── */ +.ab-mp-popup { + background: var(--ab-card-bg, #fff); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.25); + width: 640px; max-width: 90vw; + max-height: 80vh; + display: flex; flex-direction: column; + animation: ab-mp-slide-up 0.2s ease; +} + +@keyframes ab-mp-slide-up { + from { opacity: 0; transform: translateY(12px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Header ─────────────────────────────────────────────────────────── */ +.ab-mp-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px; border-bottom: 1px solid var(--ab-border, #e2e8f0); +} + +.ab-mp-title { + font-size: 16px; font-weight: 700; + color: var(--ab-text, #1e293b); +} + +.ab-mp-close { + background: none; border: none; cursor: pointer; + font-size: 22px; line-height: 1; color: var(--ab-text-muted, #64748b); + padding: 4px 8px; border-radius: 4px; +} +.ab-mp-close:hover { + background: var(--ab-bg, #f8fafc); + color: var(--ab-text, #1e293b); +} + +/* ── Search ─────────────────────────────────────────────────────────── */ +.ab-mp-search-wrap { + padding: 12px 20px; + border-bottom: 1px solid var(--ab-border, #e2e8f0); +} + +.ab-mp-search { + width: 100%; box-sizing: border-box; + padding: 10px 14px; font-size: 14px; + border: 1px solid var(--ab-border, #e2e8f0); + border-radius: 8px; outline: none; + color: var(--ab-text, #1e293b); + background: var(--ab-bg, #f8fafc); +} +.ab-mp-search:focus { + border-color: var(--ab-primary, #4f46e5); + box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff); + background: var(--ab-card-bg, #fff); +} + +/* ── Model list ─────────────────────────────────────────────────────── */ +.ab-mp-list { + flex: 1; overflow-y: auto; + padding: 4px 0; + min-height: 200px; max-height: 50vh; +} + +.ab-mp-loading, .ab-mp-empty { + padding: 40px 20px; text-align: center; + color: var(--ab-text-muted, #64748b); font-size: 14px; +} + +/* ── Model row ──────────────────────────────────────────────────────── */ +.ab-mp-row { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 20px; cursor: pointer; + transition: background 0.1s ease; + gap: 12px; +} +.ab-mp-row:hover { + background: var(--ab-primary-light, #eef2ff); +} + +.ab-mp-row-main { + display: flex; align-items: center; gap: 8px; + flex: 1; min-width: 0; +} + +.ab-mp-name { + font-size: 13px; font-weight: 600; + color: var(--ab-text, #1e293b); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} + +.ab-mp-provider { + font-size: 11px; font-weight: 500; + color: var(--ab-primary, #4f46e5); + background: var(--ab-primary-light, #eef2ff); + padding: 1px 6px; border-radius: 4px; + white-space: nowrap; flex-shrink: 0; +} + +.ab-mp-dep-badge { + font-size: 10px; font-weight: 600; + color: var(--ab-warning, #d97706); + background: #fffbeb; + padding: 1px 6px; border-radius: 4px; + white-space: nowrap; flex-shrink: 0; +} + +.ab-mp-deprecated { + opacity: 0.55; +} + +.ab-mp-row-meta { + display: flex; align-items: center; gap: 16px; + flex-shrink: 0; +} + +.ab-mp-price { + font-size: 12px; font-weight: 500; + color: var(--ab-text-muted, #64748b); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ab-mp-ctx { + font-size: 11px; font-weight: 600; + color: var(--ab-text-muted, #64748b); + background: var(--ab-bg, #f8fafc); + padding: 2px 6px; border-radius: 4px; + white-space: nowrap; min-width: 36px; text-align: center; +} + +/* ── Footer ─────────────────────────────────────────────────────────── */ +.ab-mp-footer { + display: flex; align-items: center; justify-content: space-between; + padding: 10px 20px; + border-top: 1px solid var(--ab-border, #e2e8f0); + font-size: 12px; color: var(--ab-text-muted, #64748b); +} + +/* ── Trigger button (replaces text input) ───────────────────────────── */ +.ab-mp-trigger { + display: inline-flex; align-items: center; gap: 10px; + padding: 8px 14px; + background: var(--ab-card-bg, #fff); + border: 1px solid var(--ab-border, #e2e8f0); + border-radius: 6px; cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; + max-width: 420px; width: 100%; box-sizing: border-box; + text-align: left; +} +.ab-mp-trigger:hover { + border-color: var(--ab-primary, #4f46e5); +} +.ab-mp-trigger:focus { + border-color: var(--ab-primary, #4f46e5); + box-shadow: 0 0 0 3px var(--ab-primary-light, #eef2ff); + outline: none; +} + +.ab-mp-display-name { + font-size: 13px; font-weight: 600; + color: var(--ab-text, #1e293b); + flex: 1; min-width: 0; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} + +.ab-mp-display-price { + font-size: 11px; font-weight: 500; + color: var(--ab-text-muted, #64748b); + white-space: nowrap; flex-shrink: 0; +} + +.ab-mp-display-arrow { + color: var(--ab-text-muted, #64748b); + font-size: 14px; flex-shrink: 0; +} + +/* ── Responsive ─────────────────────────────────────────────────────── */ +@media screen and (max-width: 680px) { + .ab-mp-popup { width: 95vw; max-height: 85vh; } + .ab-mp-row { padding: 10px 14px; } + .ab-mp-row-meta { gap: 8px; } +} diff --git a/assets/js/model-picker.js b/assets/js/model-picker.js new file mode 100644 index 0000000..ad25e2c --- /dev/null +++ b/assets/js/model-picker.js @@ -0,0 +1,262 @@ +/** + * Model picker popup for PRAutoBlogger admin settings. + * + * Fetches the OpenRouter model registry via AJAX, then renders a searchable + * popup overlay when the user clicks a model-select trigger button. Shows + * name, provider, input/output cost, and context window per model. + * + * @see admin/class-admin-page.php — Renders model_select fields and localizes config. + * @see services/class-open-router-model-registry.php — Server-side registry source. + */ +(function ($) { + 'use strict'; + + var config = window.prautobloggerAdmin || {}; + var cachedModels = null; + var fetchInProgress = null; + + /** + * Fetch models from the server (cached after first call). + * + * @param {string} capability Filter capability (e.g. 'text→text'). + * @return {jQuery.Deferred} Resolves with model array. + */ + function fetchModels(capability) { + if (cachedModels) { + return $.Deferred().resolve(filterModels(cachedModels, capability)); + } + + if (fetchInProgress) { + return fetchInProgress.then(function () { + return filterModels(cachedModels, capability); + }); + } + + fetchInProgress = $.ajax({ + url: config.ajaxUrl, + method: 'POST', + data: { + action: 'prautoblogger_get_models', + nonce: config.modelsNonce + }, + timeout: 15000 + }).then(function (response) { + fetchInProgress = null; + if (response.success && Array.isArray(response.data)) { + cachedModels = response.data; + return filterModels(cachedModels, capability); + } + cachedModels = []; + return []; + }, function () { + fetchInProgress = null; + cachedModels = []; + return []; + }); + + return fetchInProgress; + } + + /** + * Filter model array by capability. + * + * @param {Array} models Full model list. + * @param {string} capability Capability string to match. + * @return {Array} Filtered models. + */ + function filterModels(models, capability) { + if (!capability) return models; + return models.filter(function (m) { + return m.capabilities && m.capabilities.indexOf(capability) !== -1; + }); + } + + /** + * Format a price-per-million-tokens value for display. + * + * @param {number} price Price per 1M tokens. + * @return {string} Formatted string like "$0.25" or "Free". + */ + function formatPrice(price) { + if (!price || price <= 0) return 'Free'; + if (price < 0.01) return '$' + price.toFixed(4); + if (price < 1) return '$' + price.toFixed(2); + return '$' + price.toFixed(2); + } + + /** + * Format context length for display. + * + * @param {number} ctx Context length in tokens. + * @return {string} Formatted string like "128K" or "1M". + */ + function formatContext(ctx) { + if (!ctx) return '—'; + if (ctx >= 1000000) return (ctx / 1000000).toFixed(0) + 'M'; + if (ctx >= 1000) return Math.round(ctx / 1000) + 'K'; + return ctx.toString(); + } + + /** + * Build HTML for a single model row in the picker list. + * + * @param {Object} model Normalized model record. + * @return {string} HTML string. + */ + function renderModelRow(model) { + var deprecated = model.deprecated ? ' ab-mp-deprecated' : ''; + return ( + '
' + + '
' + + '' + escHtml(model.name) + '' + + '' + escHtml(model.provider) + '' + + (model.deprecated ? 'deprecated' : '') + + '
' + + '
' + + '' + + formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m) + + '' + + '' + formatContext(model.context_length) + '' + + '
' + + '
' + ); + } + + /** Minimal HTML escaping for display. */ + function escHtml(str) { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>'); + } + + /** Escape a string for use in an HTML attribute. */ + function escAttr(str) { + return escHtml(str).replace(/"/g, '"'); + } + + /** + * Open the model picker popup for a given trigger button. + * + * @param {jQuery} $trigger The .ab-mp-trigger button that was clicked. + */ + function openPicker($trigger) { + var fieldId = $trigger.data('field-id'); + var capability = $trigger.data('capability') || ''; + + // Build overlay + popup structure. + var $overlay = $('
'); + var $popup = $( + '
' + + '
' + + 'Select Model' + + '' + + '
' + + '
' + + '' + + '
' + + '
Loading models…
' + + '' + + '
' + ); + + $overlay.append($popup); + $('body').append($overlay); + + var $search = $popup.find('.ab-mp-search'); + var $list = $popup.find('.ab-mp-list'); + var $count = $popup.find('.ab-mp-count'); + var allModels = []; + + // Focus search after render. + setTimeout(function () { $search.focus(); }, 50); + + // Fetch and render model list. + fetchModels(capability).then(function (models) { + allModels = models; + renderList(models, ''); + }); + + /** Render filtered model list. */ + function renderList(models, query) { + var filtered = models; + if (query) { + var q = query.toLowerCase(); + filtered = models.filter(function (m) { + return (m.name && m.name.toLowerCase().indexOf(q) !== -1) || + (m.id && m.id.toLowerCase().indexOf(q) !== -1) || + (m.provider && m.provider.toLowerCase().indexOf(q) !== -1); + }); + } + + // Sort: non-deprecated first, then by provider + name. + filtered.sort(function (a, b) { + if (a.deprecated !== b.deprecated) return a.deprecated ? 1 : -1; + var pa = (a.provider || '').toLowerCase(); + var pb = (b.provider || '').toLowerCase(); + if (pa !== pb) return pa < pb ? -1 : 1; + return (a.name || '').toLowerCase().localeCompare((b.name || '').toLowerCase()); + }); + + if (filtered.length === 0) { + $list.html('
No models found.
'); + } else { + var html = ''; + for (var i = 0; i < filtered.length; i++) { + html += renderModelRow(filtered[i]); + } + $list.html(html); + } + $count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : '')); + } + + // Search input handler (debounced). + var searchTimer = null; + $search.on('input', function () { + var q = $(this).val(); + clearTimeout(searchTimer); + searchTimer = setTimeout(function () { + renderList(allModels, q); + }, 150); + }); + + // Select a model. + $list.on('click', '.ab-mp-row', function () { + var id = $(this).data('model-id'); + var model = allModels.find(function (m) { return m.id === id; }); + if (!model) return; + + // Update hidden input + display. + $('#' + fieldId).val(model.id); + $trigger.find('.ab-mp-display-name').text(model.name || model.id); + $trigger.find('.ab-mp-display-price').text( + formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m) + ); + closePicker(); + }); + + // Close handlers. + function closePicker() { + $overlay.remove(); + } + + $popup.find('.ab-mp-close').on('click', closePicker); + $overlay.on('click', function (e) { + if ($(e.target).is('.ab-mp-overlay')) closePicker(); + }); + $(document).on('keydown.abModelPicker', function (e) { + if (e.key === 'Escape') { + closePicker(); + $(document).off('keydown.abModelPicker'); + } + }); + } + + // Bind click on all model picker triggers. + $(document).on('click', '.ab-mp-trigger', function (e) { + e.preventDefault(); + openPicker($(this)); + }); + +})(jQuery); diff --git a/includes/admin/class-admin-page.php b/includes/admin/class-admin-page.php index 0f157e1..f2bd518 100755 --- a/includes/admin/class-admin-page.php +++ b/includes/admin/class-admin-page.php @@ -65,13 +65,16 @@ class PRAutoBlogger_Admin_Page { } wp_enqueue_style( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/admin.css', [], PRAUTOBLOGGER_VERSION ); + wp_enqueue_style( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/css/model-picker.css', [ 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION ); wp_enqueue_script( 'prautoblogger-admin', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/admin.js', [ 'jquery' ], PRAUTOBLOGGER_VERSION, true ); + wp_enqueue_script( 'prautoblogger-model-picker', PRAUTOBLOGGER_PLUGIN_URL . 'assets/js/model-picker.js', [ 'jquery', 'prautoblogger-admin' ], PRAUTOBLOGGER_VERSION, true ); wp_localize_script( 'prautoblogger-admin', 'prautobloggerAdmin', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'generateNonce' => wp_create_nonce( 'prautoblogger_generate_now' ), 'imageNonce' => wp_create_nonce( 'prautoblogger_generate_image' ), 'testNonce' => wp_create_nonce( 'prautoblogger_test_connection' ), + 'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ), 'reviewNonce' => wp_create_nonce( 'prautoblogger_review_queue' ), 'generatingText' => __( 'Generating...', 'prautoblogger' ), 'generateText' => __( 'Generate Now', 'prautoblogger' ), @@ -152,6 +155,10 @@ class PRAutoBlogger_Admin_Page { } break; + case 'model_select': + $this->render_model_select_field( $id, (string) $value, $args ); + break; + case 'source_status': $this->render_source_status_field(); break; @@ -174,58 +181,25 @@ class PRAutoBlogger_Admin_Page { } /** - * Render the Reddit source status indicator. + * Delegate source status rendering to the dedicated field class. * - * Shows Reddit RSS (primary) and .json (fallback) availability - * with live status checks via transient cache and rate limit info. - * - * Side effects: Reads transients for cached availability status. - * - * @return void + * @see admin/fields/class-source-status-field.php */ private function render_source_status_field(): void { - $last_collection = get_option( 'prautoblogger_last_collection_time', '' ); - $last_source_used = get_option( 'prautoblogger_last_source_used', '' ); + PRAutoBlogger_Source_Status_Field::render(); + } - echo '
'; - - // Reddit RSS — primary source. - printf( - '
' - . '' - . '%s %s' - . '%s' - . '
', - esc_html__( 'Reddit RSS', 'prautoblogger' ), - esc_html__( 'Primary', 'prautoblogger' ), - esc_html__( 'Reliable from all IPs', 'prautoblogger' ) - ); - - // Reddit .json — fallback source. - printf( - '
' - . '' - . '%s %s' - . '%s' - . '
', - esc_html__( 'Reddit .json', 'prautoblogger' ), - esc_html__( 'Fallback + Comments', 'prautoblogger' ), - esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' ) - ); - - // Last collection info. - if ( '' !== $last_collection ) { - $time_ago = human_time_diff( (int) $last_collection, time() ); - printf( - '
%s %s %s
', - esc_html__( 'Last collection:', 'prautoblogger' ), - /* translators: %s is a human-readable time difference like "2 hours" */ - esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ), - '' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : '' - ); - } - - echo '
'; + /** + * Delegate model picker rendering to the dedicated field class. + * + * @param string $id Field HTML id/name. + * @param string $value Current model id. + * @param array $args Field definition. + * + * @see admin/fields/class-openrouter-model-field.php + */ + private function render_model_select_field( string $id, string $value, array $args ): void { + PRAutoBlogger_OpenRouter_Model_Field::render( $id, $value, $args ); } /** diff --git a/includes/admin/class-settings-fields.php b/includes/admin/class-settings-fields.php index 8bc55d9..3b059dd 100755 --- a/includes/admin/class-settings-fields.php +++ b/includes/admin/class-settings-fields.php @@ -125,27 +125,30 @@ class PRAutoBlogger_Settings_Fields { [ 'id' => 'prautoblogger_analysis_model', 'label' => __( 'Analysis Model', 'prautoblogger' ), - 'type' => 'text', + 'type' => 'model_select', 'section' => 'prautoblogger_models', 'default' => PRAUTOBLOGGER_DEFAULT_ANALYSIS_MODEL, + 'capability' => 'text→text', 'description' => __( 'Used for source analysis. Pick a cheap, fast model.', 'prautoblogger' ), 'badge' => __( 'Low cost', 'prautoblogger' ), ], [ 'id' => 'prautoblogger_writing_model', 'label' => __( 'Writing Model', 'prautoblogger' ), - 'type' => 'text', + 'type' => 'model_select', 'section' => 'prautoblogger_models', 'default' => PRAUTOBLOGGER_DEFAULT_WRITING_MODEL, + 'capability' => 'text→text', 'description' => __( 'Used for article generation. Quality matters here.', 'prautoblogger' ), 'badge' => __( 'Quality', 'prautoblogger' ), ], [ 'id' => 'prautoblogger_editor_model', 'label' => __( 'Editor Model', 'prautoblogger' ), - 'type' => 'text', + 'type' => 'model_select', 'section' => 'prautoblogger_models', 'default' => PRAUTOBLOGGER_DEFAULT_EDITOR_MODEL, + 'capability' => 'text→text', 'description' => __( 'Used for the chief editor review pass.', 'prautoblogger' ), 'badge' => __( 'Quality', 'prautoblogger' ), ], diff --git a/includes/admin/fields/class-open-router-model-field.php b/includes/admin/fields/class-open-router-model-field.php new file mode 100644 index 0000000..b3d9fbb --- /dev/null +++ b/includes/admin/fields/class-open-router-model-field.php @@ -0,0 +1,78 @@ + $args Field definition from settings fields. + */ + public static function render( string $id, string $value, array $args ): void { + $capability = $args['capability'] ?? 'text→text'; + $display_name = $value ?: __( '— Select a model —', 'prautoblogger' ); + $display_price = ''; + + // Try to resolve display name + price from the cached registry. + $registry = prautoblogger()->get_executor()->get_model_registry(); + $model = $registry->find_model( $value ); + if ( null !== $model ) { + $display_name = $model['name'] ?? $value; + $in = (float) ( $model['input_price_per_m'] ?? 0 ); + $out = (float) ( $model['output_price_per_m'] ?? 0 ); + $display_price = self::format_price( $in ) . ' / ' . self::format_price( $out ); + } + + // Hidden input carries the actual value for the form submission. + printf( + '', + esc_attr( $id ), + esc_attr( $id ), + esc_attr( $value ) + ); + + // Trigger button — opens the model picker popup via JS. + printf( + '', + esc_attr( $id ), + esc_attr( $capability ), + esc_html( $display_name ), + esc_html( $display_price ) + ); + } + + /** + * Format a price-per-million-tokens value for display. + * + * @param float $price Price per 1M tokens. + * @return string Formatted price string like "$0.25" or "Free". + */ + private static function format_price( float $price ): string { + if ( $price <= 0 ) { + return __( 'Free', 'prautoblogger' ); + } + if ( $price < 0.01 ) { + return '$' . number_format( $price, 4 ); + } + return '$' . number_format( $price, 2 ); + } +} diff --git a/includes/admin/fields/class-source-status-field.php b/includes/admin/fields/class-source-status-field.php new file mode 100644 index 0000000..5c0a62f --- /dev/null +++ b/includes/admin/fields/class-source-status-field.php @@ -0,0 +1,66 @@ +'; + + // Reddit RSS — primary source. + printf( + '
' + . '' + . '%s %s' + . '%s' + . '
', + esc_html__( 'Reddit RSS', 'prautoblogger' ), + esc_html__( 'Primary', 'prautoblogger' ), + esc_html__( 'Reliable from all IPs', 'prautoblogger' ) + ); + + // Reddit .json — fallback source. + printf( + '
' + . '' + . '%s %s' + . '%s' + . '
', + esc_html__( 'Reddit .json', 'prautoblogger' ), + esc_html__( 'Fallback + Comments', 'prautoblogger' ), + esc_html__( 'May be IP-blocked on some hosts', 'prautoblogger' ) + ); + + // Last collection info. + if ( '' !== $last_collection ) { + $time_ago = human_time_diff( (int) $last_collection, time() ); + printf( + '
%s %s %s
', + esc_html__( 'Last collection:', 'prautoblogger' ), + /* translators: %s is a human-readable time difference like "2 hours" */ + esc_html( sprintf( __( '%s ago', 'prautoblogger' ), $time_ago ) ), + '' !== $last_source_used ? esc_html( sprintf( __( 'via %s', 'prautoblogger' ), $last_source_used ) ) : '' + ); + } + + echo ''; + } +} diff --git a/includes/class-autoloader.php b/includes/class-autoloader.php index 8543373..471d363 100755 --- a/includes/class-autoloader.php +++ b/includes/class-autoloader.php @@ -22,6 +22,7 @@ class PRAutoBlogger_Autoloader { private static array $directories = [ '', // includes/ root 'admin/', + 'admin/fields/', 'core/', 'providers/', 'models/', diff --git a/includes/class-executor.php b/includes/class-executor.php index 2bd1a53..deb7b02 100644 --- a/includes/class-executor.php +++ b/includes/class-executor.php @@ -152,6 +152,34 @@ class PRAutoBlogger_Executor { } } + /** + * AJAX handler: return the model registry for the admin model picker. + * + * Returns the full cached model list. If the registry is empty, triggers + * a refresh first. Called by the model-picker.js popup. + * + * Side effects: may trigger an OpenRouter API call if registry is empty. + */ + public function on_ajax_get_models(): void { + check_ajax_referer( 'prautoblogger_get_models', 'nonce' ); + + if ( ! current_user_can( 'manage_options' ) ) { + wp_send_json_error( [ 'message' => __( 'Insufficient permissions.', 'prautoblogger' ) ], 403 ); + return; + } + + $registry = $this->get_model_registry(); + $models = $registry->get_models(); + + // Auto-refresh if the registry has never been populated. + if ( empty( $models ) ) { + $registry->refresh( true ); + $models = $registry->get_models(); + } + + wp_send_json_success( $models ); + } + /** AJAX handler: test API connections (OpenRouter, Reddit). */ public function on_ajax_test_connection(): void { check_ajax_referer( 'prautoblogger_test_connection', 'nonce' ); diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index 4a045db..31c1d9b 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -94,6 +94,7 @@ class PRAutoBlogger { add_action( 'wp_ajax_prautoblogger_generate_now', [ $this->executor, 'on_ajax_generate_now' ] ); add_action( 'wp_ajax_prautoblogger_generate_image', [ $this->executor, 'on_ajax_generate_image' ] ); add_action( 'wp_ajax_prautoblogger_test_connection', [ $this->executor, 'on_ajax_test_connection' ] ); + add_action( 'wp_ajax_prautoblogger_get_models', [ $this->executor, 'on_ajax_get_models' ] ); $review_queue = new PRAutoBlogger_Review_Queue(); add_action( 'wp_ajax_prautoblogger_approve_post', [ $review_queue, 'on_ajax_approve_post' ] );