/** * Model picker popup for PRAutoBlogger admin settings. * * Fetches the OpenRouter model registry via AJAX, then renders a searchable, * sortable popup overlay when the user clicks a model-select trigger button. * Columns: Model, Price (in / out per 1M tokens), Context window. * Each column header is clickable to sort ascending/descending. * * @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; // Persists across open/close so the user's preferred sort is remembered. var sortState = { col: 'name', dir: 'asc' }; /** * 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 ) { // Image models are embedded in the page config — no AJAX needed. if ( capability === 'image_generation' ) { var imgModels = config.imageModels || []; return $.Deferred().resolve( imgModels ); } 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; } ); } /** * Sort a model array according to the current sortState. * Deprecated models always sink to the bottom regardless of column. * * @param {Array} arr Model array to sort (not mutated — returns a new array). * @return {Array} Sorted copy. */ function applySort( arr ) { var col = sortState.col; var dir = sortState.dir; return arr.slice().sort( function ( a, b ) { // Deprecated always last. if ( a.deprecated !== b.deprecated ) { return a.deprecated ? 1 : -1; } var valA, valB; if ( col === 'price' ) { // Image models use cost_per_image; text models use input_price_per_m. valA = ( a.cost_per_image !== undefined ) ? a.cost_per_image : ( a.input_price_per_m || 0 ); valB = ( b.cost_per_image !== undefined ) ? b.cost_per_image : ( b.input_price_per_m || 0 ); } else if ( col === 'context' ) { valA = a.context_length || 0; valB = b.context_length || 0; } else { // Default: sort by model name. valA = ( a.name || '' ).toLowerCase(); valB = ( b.name || '' ).toLowerCase(); } if ( valA < valB ) return dir === 'asc' ? -1 : 1; if ( valA > valB ) return dir === 'asc' ? 1 : -1; return 0; } ); } /** * 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' : ''; var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0; var priceHtml, priceTitle, metaRight; if ( isImageModel ) { priceHtml = '$' + model.cost_per_image.toFixed( 4 ) + '/image'; priceTitle = 'Cost per generated image'; metaRight = model.description ? '' + escHtml( model.description ) + '' : ''; } else { priceHtml = formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m ); priceTitle = 'Input / Output per 1M tokens'; metaRight = '' + formatContext( model.context_length ) + ''; } return ( '
' + '
' + '' + escHtml( model.name ) + '' + '' + escHtml( model.provider ) + '' + ( model.deprecated ? 'deprecated' : '' ) + '
' + '
' + metaRight + '' + priceHtml + '' + '
' + '
' ); } /** 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' ) || ''; var isImageCap = capability === 'image_generation'; var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)'; // ── Build overlay + popup ─────────────────────────────────────── 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 $colHeaders = $popup.find( '.ab-mp-col-headers' ); var allModels = []; var currentQuery = ''; // Focus search after render. setTimeout( function () { $search.focus(); }, 50 ); // ── Sort header indicators ────────────────────────────────────── /** Update active-column highlight and sort arrow glyphs. */ function updateSortHeaders() { $colHeaders.find( '.ab-mp-col-header' ).each( function () { var col = $( this ).data( 'col' ); var $arrow = $( this ).find( '.ab-mp-sort-arrow' ); if ( col === sortState.col ) { $( this ).addClass( 'ab-mp-col-active' ); $arrow.text( sortState.dir === 'asc' ? ' ▲' : ' ▼' ); } else { $( this ).removeClass( 'ab-mp-col-active' ); $arrow.text( ' ⇅' ); } } ); } // ── Render ────────────────────────────────────────────────────── /** Render filtered + sorted 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 ); } ); } filtered = applySort( filtered ); 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' : '' ) ); updateSortHeaders(); } // Initial render after data loads. fetchModels( capability ).then( function ( models ) { allModels = models; renderList( allModels, currentQuery ); } ); // ── Column header sort clicks ─────────────────────────────────── $colHeaders.on( 'click', '.ab-mp-col-header', function () { var col = $( this ).data( 'col' ); if ( sortState.col === col ) { sortState.dir = sortState.dir === 'asc' ? 'desc' : 'asc'; } else { sortState.col = col; sortState.dir = 'asc'; } renderList( allModels, currentQuery ); } ); // ── Search (debounced) ────────────────────────────────────────── var searchTimer = null; $search.on( 'input', function () { currentQuery = $( this ).val(); clearTimeout( searchTimer ); searchTimer = setTimeout( function () { renderList( allModels, currentQuery ); }, 150 ); } ); // ── Select a model ────────────────────────────────────────────── $list.on( 'click', '.ab-mp-row', function () { var id = $( this ).data( 'model-id' ); var model = null; for ( var i = 0; i < allModels.length; i++ ) { if ( allModels[ i ].id === id ) { model = allModels[ i ]; break; } } if ( ! model ) return; $( '#' + fieldId ).val( model.id ); $trigger.find( '.ab-mp-display-name' ).text( model.name || model.id ); var isImageModel = model.cost_per_image !== undefined && model.cost_per_image > 0; var priceText = isImageModel ? '$' + model.cost_per_image.toFixed( 4 ) + '/image' : formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m ); $trigger.find( '.ab-mp-display-price' ).text( priceText ); closePicker(); } ); // ── Close ─────────────────────────────────────────────────────── 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 );