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:
peptiderepo
2026-04-18 02:53:40 +07:00
parent dcc8b7b183
commit 31f303a831
9 changed files with 670 additions and 51 deletions

206
assets/css/model-picker.css Normal file
View 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
View 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/** Escape a string for use in an HTML attribute. */
function escAttr(str) {
return escHtml(str).replace(/"/g, '&quot;');
}
/**
* 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">&times;</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);