/** * 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 ( '