feat: interactive model picker with searchable popup and pricing
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 <noreply@anthropic.com>
This commit is contained in:
206
assets/css/model-picker.css
Normal file
206
assets/css/model-picker.css
Normal file
@@ -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; }
|
||||
}
|
||||
262
assets/js/model-picker.js
Normal file
262
assets/js/model-picker.js
Normal file
@@ -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 (
|
||||
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr(model.id) + '">' +
|
||||
'<div class="ab-mp-row-main">' +
|
||||
'<span class="ab-mp-name">' + escHtml(model.name) + '</span>' +
|
||||
'<span class="ab-mp-provider">' + escHtml(model.provider) + '</span>' +
|
||||
(model.deprecated ? '<span class="ab-mp-dep-badge">deprecated</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="ab-mp-row-meta">' +
|
||||
'<span class="ab-mp-price" title="Input / Output per 1M tokens">' +
|
||||
formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m) +
|
||||
'</span>' +
|
||||
'<span class="ab-mp-ctx" title="Context window">' + formatContext(model.context_length) + '</span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}
|
||||
|
||||
/** Minimal HTML escaping for display. */
|
||||
function escHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g, '&').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 = $('<div class="ab-mp-overlay"></div>');
|
||||
var $popup = $(
|
||||
'<div class="ab-mp-popup">' +
|
||||
'<div class="ab-mp-header">' +
|
||||
'<span class="ab-mp-title">Select Model</span>' +
|
||||
'<button type="button" class="ab-mp-close">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="ab-mp-search-wrap">' +
|
||||
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
|
||||
'</div>' +
|
||||
'<div class="ab-mp-list"><div class="ab-mp-loading">Loading models…</div></div>' +
|
||||
'<div class="ab-mp-footer">' +
|
||||
'<span class="ab-mp-count"></span>' +
|
||||
'<span class="ab-mp-price-label">Prices per 1M tokens (in / out)</span>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
|
||||
$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('<div class="ab-mp-empty">No models found.</div>');
|
||||
} 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);
|
||||
@@ -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 '<div class="ab-source-status">';
|
||||
|
||||
// Reddit RSS — primary source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
||||
esc_html__( 'Primary', 'prautoblogger' ),
|
||||
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
||||
);
|
||||
|
||||
// Reddit .json — fallback source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
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(
|
||||
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
||||
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 '</div>';
|
||||
/**
|
||||
* Delegate model picker rendering to the dedicated field class.
|
||||
*
|
||||
* @param string $id Field HTML id/name.
|
||||
* @param string $value Current model id.
|
||||
* @param array<string, mixed> $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 );
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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' ),
|
||||
],
|
||||
|
||||
78
includes/admin/fields/class-open-router-model-field.php
Normal file
78
includes/admin/fields/class-open-router-model-field.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the interactive model-picker field for OpenRouter models.
|
||||
*
|
||||
* What: a button showing the current model's name + pricing. Clicking it
|
||||
* opens a JS-powered popup (model-picker.js) with a searchable list.
|
||||
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||
* for fields with type 'model_select'.
|
||||
* Dependencies: PRAutoBlogger_Model_Registry_Interface (cached registry lookup).
|
||||
*
|
||||
* @see admin/class-admin-page.php — Calls render() from the main field switch.
|
||||
* @see assets/js/model-picker.js — JS popup that fetches + displays models.
|
||||
* @see services/interface-model-registry.php — Registry interface for find_model().
|
||||
*/
|
||||
class PRAutoBlogger_OpenRouter_Model_Field {
|
||||
|
||||
/**
|
||||
* Render the model picker trigger button and hidden input.
|
||||
*
|
||||
* @param string $id Field HTML id/name attribute.
|
||||
* @param string $value Current model id (e.g. 'google/gemini-2.5-flash-lite').
|
||||
* @param array<string, mixed> $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(
|
||||
'<input type="hidden" id="%s" name="%s" value="%s" />',
|
||||
esc_attr( $id ),
|
||||
esc_attr( $id ),
|
||||
esc_attr( $value )
|
||||
);
|
||||
|
||||
// Trigger button — opens the model picker popup via JS.
|
||||
printf(
|
||||
'<button type="button" class="ab-mp-trigger" data-field-id="%s" data-capability="%s">'
|
||||
. '<span class="ab-mp-display-name">%s</span>'
|
||||
. '<span class="ab-mp-display-price">%s</span>'
|
||||
. '<span class="ab-mp-display-arrow">▾</span>'
|
||||
. '</button>',
|
||||
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 );
|
||||
}
|
||||
}
|
||||
66
includes/admin/fields/class-source-status-field.php
Normal file
66
includes/admin/fields/class-source-status-field.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Renders the Reddit source status indicator in the admin settings.
|
||||
*
|
||||
* What: shows RSS (primary) and .json (fallback) availability with
|
||||
* last-collection timestamp.
|
||||
* Who calls it: PRAutoBlogger_Admin_Page::render_field() delegates here
|
||||
* for fields with type 'source_status'.
|
||||
* Dependencies: None — reads wp_options only.
|
||||
*
|
||||
* @see admin/class-admin-page.php — Calls render() from the main field switch.
|
||||
*/
|
||||
class PRAutoBlogger_Source_Status_Field {
|
||||
|
||||
/**
|
||||
* Render the Reddit source status indicator.
|
||||
*
|
||||
* Side effects: reads wp_options for last collection time.
|
||||
*/
|
||||
public static function render(): void {
|
||||
$last_collection = get_option( 'prautoblogger_last_collection_time', '' );
|
||||
$last_source_used = get_option( 'prautoblogger_last_source_used', '' );
|
||||
|
||||
echo '<div class="ab-source-status">';
|
||||
|
||||
// Reddit RSS — primary source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
esc_html__( 'Reddit RSS', 'prautoblogger' ),
|
||||
esc_html__( 'Primary', 'prautoblogger' ),
|
||||
esc_html__( 'Reliable from all IPs', 'prautoblogger' )
|
||||
);
|
||||
|
||||
// Reddit .json — fallback source.
|
||||
printf(
|
||||
'<div class="ab-source-row">'
|
||||
. '<span class="ab-source-dot ab-status-ok"></span>'
|
||||
. '<strong>%s</strong> <span class="ab-source-badge ab-badge-secondary">%s</span>'
|
||||
. '<span class="ab-source-label">%s</span>'
|
||||
. '</div>',
|
||||
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(
|
||||
'<div class="ab-source-meta">%s <strong>%s</strong> %s</div>',
|
||||
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 '</div>';
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ class PRAutoBlogger_Autoloader {
|
||||
private static array $directories = [
|
||||
'', // includes/ root
|
||||
'admin/',
|
||||
'admin/fields/',
|
||||
'core/',
|
||||
'providers/',
|
||||
'models/',
|
||||
|
||||
@@ -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' );
|
||||
|
||||
@@ -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' ] );
|
||||
|
||||
Reference in New Issue
Block a user