Compare commits

..

12 Commits

Author SHA1 Message Date
peptiderepo
6df75b7475 fix: expand single-line associative array to multi-line (PHPCS L89) 2026-04-24 12:01:24 +07:00
peptiderepo
33ddb826ca chore: revert ci.yml to summary report 2026-04-24 12:01:04 +07:00
peptiderepo
244e39828b chore: temp - full PHPCS output for diagnosis (revert after) 2026-04-24 11:59:34 +07:00
peptiderepo
c51a1eb47c fix: remove extra trailing blank line at EOF (PHPCS SuperfluousWhitespace) 2026-04-24 11:55:12 +07:00
peptiderepo
5f2dcb66aa fix: align => operators in push_to_opik payload array (PHPCS) 2026-04-24 11:46:14 +07:00
peptiderepo
a02e8df5e6 fix: align => operators in early-return array (PHPCS) 2026-04-24 11:44:12 +07:00
peptiderepo
079c020e7b style(phpcs): normalize variable assignment alignment padding in eval runner 2026-04-24 11:39:57 +07:00
peptiderepo
2dde5610db style(phpcs): remove alignment padding in eval runner constructor 2026-04-24 11:32:09 +07:00
peptiderepo
644ffbac21 style(phpcs): fix register_cli_hooks indentation + filter_block_false_updates double-tab 2026-04-24 11:32:07 +07:00
peptiderepo
c79fbb570e feat: wave 2 eval harness with lm-as-judge and cli command
New eval framework:
- eval/dataset.json: 20 frozen peptide topic eval items
- class-opik-eval-scorer.php: LLM-as-judge using Gemini Flash 1.5
  Scores on: scientific_signal, readability, style_adherence (0-10)
- class-opik-eval-runner.php: orchestrator for full eval pass
  Loads dataset, runs generation (no publish/images), scores, pushes to Opik
  run(int $limit = 0, bool $dry_run = false): array
- class-wp-cli-commands.php: registers wp prautoblogger opik:eval
  Flags: --limit=<n> (default 0 = all), --dry-run (score only)

Features:
- Rate limiting: 500ms between items
- Cost tracking: eval judge calls logged to opik_eval_judge stage
- Dry-run mode: test locally without Opik API push
- Experiment naming: prautoblogger-eval-v<VERSION>-<YYYY-MM-DD>

All files <= 300 lines. Version 0.13.0. Ready for PR.
2026-04-24 04:25:41 +00:00
peptiderepo
575d0b97b9 feat: add opik instrumentation to image and content pipeline
- Add optional $trace_context parameter to PRAutoBlogger_Image_Prompt_Builder
- Wrap LLM call in image_prompt_rewrite span when trace context available
- Pass trace context through from image pipeline
- Add $eval_mode parameter to content generator generate() method
- Define PRAUTOBLOGGER_EVAL_MODE constant to suppress side effects
- Add generate_eval() convenience method
- Register WP-CLI hook in main orchestrator
- Bump version to 0.13.0
2026-04-24 04:25:31 +00:00
peptiderepo
50ae7ca530 refactor: extract image scene parser from prompt builder
Split class-image-prompt-builder.php (331 lines) into two files:
- class-image-prompt-builder.php (now ~300 lines)
- class-image-scene-parser.php (~91 lines) with three public static methods:
  - parse_scene_and_caption()
  - extract_first_paragraph()
  - synthesize_visual_concepts_fallback()

Builder now calls these via PRAutoBlogger_Image_Scene_Parser:: static calls.
Maintains identical external behavior; internal refactor only.

Target: prevents CI violations when adding Opik instrumentation.
2026-04-24 04:25:27 +00:00
9 changed files with 185 additions and 588 deletions

View File

@@ -45,15 +45,8 @@ jobs:
name: PHPCS (WordPress standards)
runs-on: ubuntu-latest
continue-on-error: false # now strict after PHPCS cleanup in v0.8.3
permissions:
contents: write # allows phpcbf auto-fix commits back to the PR branch
steps:
- uses: actions/checkout@v4
with:
# Check out the actual branch so phpcbf can push fixes back.
# github.head_ref is set on PR events; github.ref_name on push events.
ref: ${{ github.head_ref || github.ref_name }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
@@ -71,28 +64,13 @@ jobs:
"wp-coding-standards/wpcs:^3.0" \
"phpcsstandards/phpcsutils:^1.0" \
"phpcsstandards/phpcsextra:^1.0"
- name: Auto-fix with phpcbf (commit back to branch if changed)
# phpcbf exits 1 when it makes fixes, 0 when nothing to fix — both are success here.
# Commits made via GITHUB_TOKEN do not trigger a new CI run (GitHub prevents loops).
run: |
phpcbf --standard=phpcs.xml \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
includes/ prautoblogger.php uninstall.php || true
if ! git diff --quiet; then
git config user.name "peptiderepo"
git config user.email "peptiderepo@users.noreply.github.com"
git add -A
git commit -m "style: auto-fix PHPCS violations via phpcbf"
git push
fi
- name: Run PHPCS (only non-auto-fixable violations can fail here)
- name: Run PHPCS
run: |
phpcs --standard=phpcs.xml \
--extensions=php \
--ignore=vendor/*,tests/*,node_modules/* \
--warning-severity=0 \
--report=full \
--report=summary \
includes/ prautoblogger.php uninstall.php
phpunit:

View File

@@ -4,20 +4,21 @@ on:
push:
branches: [main]
concurrency:
group: deploy-prautoblogger-${{ github.ref }}
cancel-in-progress: true
jobs:
validate:
name: PHP Lint Gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
- name: Checkout code
uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.1'
coverage: none
- name: PHP Lint Check
run: |
set -e
@@ -28,62 +29,38 @@ jobs:
-not -path './.github/*' \
-print0 | xargs -0 -n1 -P4 php -l > /dev/null
deploy-staging:
name: Deploy to Staging
deploy:
needs: validate
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy via rsync → staging
run: |
rsync -avz --delete \
--exclude '.git' --exclude '.github' \
--exclude 'tests' --exclude 'phpunit.xml.dist' \
--exclude 'composer.json' --exclude 'composer.lock' \
--exclude 'vendor' --exclude '.distignore' \
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/staging/wp-content/plugins/prautoblogger/
- name: Staging health check
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://staging.peptiderepo.com/ || echo "000")
echo "Staging responded: HTTP $STATUS"
[ "$STATUS" = "000" ] && echo "Warning: staging DNS may still be propagating" || true
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key
deploy-production:
name: Deploy to Production
needs: [validate, deploy-staging]
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -p ${{ secrets.SSH_PORT }} ${{ secrets.SSH_HOST }} >> ~/.ssh/known_hosts 2>/dev/null
- name: Deploy via rsync → production
- name: Deploy via rsync
run: |
rsync -avz --delete \
--exclude '.git' --exclude '.github' \
--exclude 'tests' --exclude 'phpunit.xml.dist' \
--exclude 'composer.json' --exclude 'composer.lock' \
--exclude 'vendor' --exclude '.distignore' \
--exclude 'ARCHITECTURE.md' --exclude 'CONVENTIONS.md' \
--exclude '.git' \
--exclude '.github' \
--exclude 'tests' \
--exclude 'phpunit.xml.dist' \
--exclude 'composer.json' \
--exclude 'composer.lock' \
--exclude 'vendor' \
--exclude '.distignore' \
--exclude 'ARCHITECTURE.md' \
--exclude 'CONVENTIONS.md' \
-e "ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }}" \
./ ${{ secrets.SSH_USERNAME }}@${{ secrets.SSH_HOST }}:~/domains/peptiderepo.com/public_html/wp-content/plugins/prautoblogger/
- name: Purge LiteSpeed Cache
run: |
ssh -i ~/.ssh/deploy_key -p ${{ secrets.SSH_PORT }} \
@@ -91,15 +68,22 @@ jobs:
"cd ~/domains/peptiderepo.com/public_html && \
wp litespeed-purge all 2>/dev/null || \
(find wp-content/litespeed/ -type f -delete 2>/dev/null; touch .lscache_purge 2>/dev/null) || true"
- name: Production health check
- name: Health check
run: |
sleep 5
STATUS=$(curl -s -o /dev/null -w "%{http_code}" https://peptiderepo.com)
# 200 = normal healthy response.
# 403 = peptide-starter-theme v1.5.2 CF-peer validator rejecting this
# GHA-runner direct-to-origin request as non-Cloudflare traffic.
# That rejection is the validator working — itself a positive
# liveness signal. Real user traffic via Cloudflare gets 200.
if [ "$STATUS" != "200" ] && [ "$STATUS" != "403" ]; then
echo "ERROR: Site returned unexpected HTTP $STATUS after deploy"
exit 1
fi
echo "Production healthy (HTTP $STATUS)"
echo "Site is responsive (HTTP $STATUS)"
- name: Cleanup
if: always()
run: rm -f ~/.ssh/deploy_key

View File

@@ -5,59 +5,7 @@ All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.13.8] - 2026-04-26
### Added
- Image model picker expanded from 7 to 21 models
- New Runware text-to-image models: Stable Diffusion 3, HiDream-I1 Full/Dev/Fast,
FLUX.1 Krea [dev], Qwen-Image, FLUX.2 [dev], FLUX.2 [klein] 9B, FLUX.2 [klein] 4B,
GLM-Image, Z-Image, Z-Image-Turbo, TwinFlow Z-Image-Turbo
- New OpenRouter model: openai/gpt-5.4-image-2
- Registry now includes last-verified date and update instructions in source comment
All notable changes to PRAutoBlogger will be documented in this file.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project uses [Semantic Versioning](https://semver.org/).
## [0.13.7] - 2026-04-24
### Fixed
- Posts list column layout: title was set to 35% which, combined with 3 fixed-width PRAB columns, left ~50px for every remaining column (Author, Categories, Tags, Date all word-wrapping). Replaced with a balanced budget: title 22%, author 6%, categories 7%, tags 9%, date 8%, PRAB columns 85/85/50px, Peptide Topics 75px.
## [0.13.6] - 2026-04-24
### Fixed
- Peptide Topics column still rendering vertically: WordPress posts table uses `table-layout: fixed` which ignores `min-width` entirely. Changed to explicit `width: 80px` which is respected under fixed-layout tables.
## [0.13.5] - 2026-04-24
### Fixed
- Cost column in Posts list was always showing $0.00: a PHP `// phpcs:ignore` comment was embedded inside the SQL string literal, causing a MariaDB syntax error on every query. Moved comment above the `prepare()` call.
- Peptide Topics column header in Posts list was being squeezed to near-zero width by the fixed-width PRAB columns. Added `min-width: 80px` to `.column-taxonomy-peptide_topic`.
## [0.13.4] - 2026-04-24
### Fixed
- Model picker price column now consistently right-aligned. Root cause: price and description were siblings inside .ab-mp-row-meta, with description first and variable-length — this shifted price left/right per row. Fix: moved price to last position with flex-shrink: 0 so its right edge is always pinned to the row edge; capped description at max-width 200px with ellipsis; swapped column headers to match new DOM order.
## [0.13.3] - 2026-04-24
### Fixed
- Price column header now right-aligned with `min-width: 90px` to match data cells — header and prices now track the same column edge.
## [0.13.2] - 2026-04-24
### Changed
- Model picker: added sortable column headers (Model, Price, Context); clicking a header sorts the list ascending/descending; active column highlighted with sort arrow; sort preference persists while the picker is open.
## [0.12.3] — 2026-04-24
## [0.13.1] - 2026-04-24
### Fixed
- Model picker price column now right-aligned with a minimum width so values like `$0.0006/image` and `$0.0800/image` stack cleanly instead of floating at uneven offsets.
## [0.13.0] - 2026-04-24
### Added

View File

@@ -142,7 +142,6 @@
color: var(--ab-text-muted, #64748b);
font-variant-numeric: tabular-nums;
white-space: nowrap;
min-width: 90px; text-align: right; flex-shrink: 0;
}
.ab-mp-ctx {
@@ -151,7 +150,6 @@
background: var(--ab-bg, #f8fafc);
padding: 2px 6px; border-radius: 4px;
white-space: nowrap; min-width: 36px; text-align: center;
max-width: 200px; overflow: hidden; text-overflow: ellipsis;
}
/* ── Footer ─────────────────────────────────────────────────────────── */
@@ -206,59 +204,3 @@
.ab-mp-row { padding: 10px 14px; }
.ab-mp-row-meta { gap: 8px; }
}
/* ── Column headers (sortable) ──────────────────────────────────────── */
.ab-mp-col-headers {
display: flex; align-items: center; justify-content: space-between;
padding: 5px 20px;
border-bottom: 1px solid var(--ab-border, #e2e8f0);
background: var(--ab-bg, #f8fafc);
gap: 12px; /* must match .ab-mp-row gap */
}
.ab-mp-col-headers-meta {
display: flex; align-items: center;
gap: 16px; /* must match .ab-mp-row-meta gap */
flex-shrink: 0;
}
.ab-mp-col-header {
display: inline-flex; align-items: center;
font-size: 10px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ab-text-muted, #64748b);
cursor: pointer; user-select: none;
background: none; border: none; padding: 3px 0;
white-space: nowrap; line-height: 1.2;
transition: color 0.1s ease;
}
.ab-mp-col-header:hover {
color: var(--ab-text, #1e293b);
}
.ab-mp-col-header.ab-mp-col-active {
color: var(--ab-primary, #4f46e5);
}
/* "Model" header spans the left flex-1 area */
.ab-mp-col-model {
flex: 1; min-width: 0;
}
/* "Price" header width matches .ab-mp-price */
.ab-mp-col-price {
font-variant-numeric: tabular-nums;
min-width: 90px; justify-content: flex-end;
}
/* "Context" header width matches .ab-mp-ctx (min-width 36px + padding) */
.ab-mp-col-ctx {
min-width: 48px; justify-content: center; text-align: center;
}
.ab-mp-sort-arrow {
font-size: 9px; opacity: 0.5;
transition: opacity 0.1s ease;
}
.ab-mp-col-header.ab-mp-col-active .ab-mp-sort-arrow {
opacity: 1;
}

View File

@@ -1,10 +1,9 @@
/**
* 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.
* 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.
@@ -12,57 +11,54 @@
(function ($) {
'use strict';
var config = window.prautobloggerAdmin || {};
var cachedModels = null;
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 ) {
function fetchModels(capability) {
// Image models are embedded in the page config — no AJAX needed.
if ( capability === 'image_generation' ) {
if (capability === 'image_generation') {
var imgModels = config.imageModels || [];
return $.Deferred().resolve( imgModels );
return $.Deferred().resolve(imgModels);
}
if ( cachedModels ) {
return $.Deferred().resolve( filterModels( cachedModels, capability ) );
if (cachedModels) {
return $.Deferred().resolve(filterModels(cachedModels, capability));
}
if ( fetchInProgress ) {
return fetchInProgress.then( function () {
return filterModels( cachedModels, capability );
} );
if (fetchInProgress) {
return fetchInProgress.then(function () {
return filterModels(cachedModels, capability);
});
}
fetchInProgress = $.ajax( {
url: config.ajaxUrl,
fetchInProgress = $.ajax({
url: config.ajaxUrl,
method: 'POST',
data: {
action: 'prautoblogger_get_models',
nonce: config.modelsNonce
nonce: config.modelsNonce
},
timeout: 15000
} ).then( function ( response ) {
}).then(function (response) {
fetchInProgress = null;
if ( response.success && Array.isArray( response.data ) ) {
if (response.success && Array.isArray(response.data)) {
cachedModels = response.data;
return filterModels( cachedModels, capability );
return filterModels(cachedModels, capability);
}
cachedModels = [];
return [];
}, function () {
fetchInProgress = null;
cachedModels = [];
cachedModels = [];
return [];
} );
});
return fetchInProgress;
}
@@ -74,48 +70,11 @@
* @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' ) {
valA = a.input_price_per_m || 0;
valB = 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;
} );
function filterModels(models, capability) {
if (!capability) return models;
return models.filter(function (m) {
return m.capabilities && m.capabilities.indexOf(capability) !== -1;
});
}
/**
@@ -124,11 +83,11 @@
* @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 );
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);
}
/**
@@ -137,10 +96,10 @@
* @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';
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();
}
@@ -150,47 +109,45 @@
* @param {Object} model Normalized model record.
* @return {string} HTML string.
*/
function renderModelRow( model ) {
var deprecated = model.deprecated ? ' ab-mp-deprecated' : '';
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';
if (isImageModel) {
priceHtml = '$' + model.cost_per_image.toFixed(4) + '/image';
priceTitle = 'Cost per generated image';
metaRight = model.description
? '<span class="ab-mp-ctx" title="Details">' + escHtml( model.description ) + '</span>'
: '';
metaRight = model.description ? '<span class="ab-mp-ctx" title="Details">' + escHtml(model.description) + '</span>' : '';
} else {
priceHtml = formatPrice( model.input_price_per_m ) + ' / ' + formatPrice( model.output_price_per_m );
priceHtml = formatPrice(model.input_price_per_m) + ' / ' + formatPrice(model.output_price_per_m);
priceTitle = 'Input / Output per 1M tokens';
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext( model.context_length ) + '</span>';
metaRight = '<span class="ab-mp-ctx" title="Context window">' + formatContext(model.context_length) + '</span>';
}
return (
'<div class="ab-mp-row' + deprecated + '" data-model-id="' + escAttr( model.id ) + '">' +
'<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>' : '' ) +
'<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">' +
metaRight +
'<span class="ab-mp-price" title="' + priceTitle + '">' + priceHtml + '</span>' +
metaRight +
'</div>' +
'</div>'
);
}
/** Minimal HTML escaping for display. */
function escHtml( str ) {
if ( ! str ) return '';
return str.replace( /&/g, '&amp;' ).replace( /</g, '&lt;' ).replace( />/g, '&gt;' );
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;' );
function escAttr(str) {
return escHtml(str).replace(/"/g, '&quot;');
}
/**
@@ -198,16 +155,16 @@
*
* @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' ) || '';
function openPicker($trigger) {
var fieldId = $trigger.data('field-id');
var capability = $trigger.data('capability') || '';
// Build overlay + popup structure.
var isImageCap = capability === 'image_generation';
var priceLabel = isImageCap ? 'Cost per generated image' : 'Prices per 1M tokens (in / out)';
// ── Build overlay + popup ───────────────────────────────────────
var $overlay = $( '<div class="ab-mp-overlay"></div>' );
var $popup = $(
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>' +
@@ -216,13 +173,6 @@
'<div class="ab-mp-search-wrap">' +
'<input type="text" class="ab-mp-search" placeholder="Search models…" autocomplete="off" />' +
'</div>' +
'<div class="ab-mp-col-headers">' +
'<button type="button" class="ab-mp-col-header ab-mp-col-model" data-col="name">Model <span class="ab-mp-sort-arrow"></span></button>' +
'<div class="ab-mp-col-headers-meta">' +
'<button type="button" class="ab-mp-col-header ab-mp-col-ctx" data-col="context">Context <span class="ab-mp-sort-arrow"></span></button>' +
'<button type="button" class="ab-mp-col-header ab-mp-col-price" data-col="price">Price (in / out) <span class="ab-mp-sort-arrow"></span></button>' +
'</div>' +
'</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>' +
@@ -231,132 +181,105 @@
'</div>'
);
$overlay.append( $popup );
$( 'body' ).append( $overlay );
$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 = '';
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 );
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( ' ⇅' );
}
} );
}
// Fetch and render model list.
fetchModels(capability).then(function (models) {
allModels = models;
renderList(models, '');
});
// ── Render ──────────────────────────────────────────────────────
/** Render filtered + sorted model list. */
function renderList( models, query ) {
/** Render filtered model list. */
function renderList(models, query) {
var filtered = models;
if ( query ) {
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 = 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 );
// 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>' );
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 ] );
for (var i = 0; i < filtered.length; i++) {
html += renderModelRow(filtered[i]);
}
$list.html( html );
$list.html(html);
}
$count.text( filtered.length + ' model' + ( filtered.length !== 1 ? 's' : '' ) );
updateSortHeaders();
$count.text(filtered.length + ' model' + (filtered.length !== 1 ? 's' : ''));
}
// 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) ──────────────────────────────────────────
// Search input handler (debounced).
var searchTimer = null;
$search.on( 'input', function () {
currentQuery = $( this ).val();
clearTimeout( searchTimer );
searchTimer = setTimeout( function () {
renderList( allModels, currentQuery );
}, 150 );
} );
$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 = null;
for ( var i = 0; i < allModels.length; i++ ) {
if ( allModels[ i ].id === id ) { model = allModels[ i ]; break; }
}
if ( ! model ) return;
// 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;
$( '#' + fieldId ).val( model.id );
$trigger.find( '.ab-mp-display-name' ).text( model.name || model.id );
// Update hidden input + display.
$('#' + 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 );
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 ───────────────────────────────────────────────────────
// 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' ) {
$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' );
$(document).off('keydown.abModelPicker');
}
} );
});
}
// Bind click on all model picker triggers.
$( document ).on( 'click', '.ab-mp-trigger', function ( e ) {
$(document).on('click', '.ab-mp-trigger', function (e) {
e.preventDefault();
openPicker( $( this ) );
} );
openPicker($(this));
});
} )( jQuery );
})(jQuery);

View File

@@ -8,8 +8,6 @@ declare(strict_types=1);
*
* What: Returns a hardcoded list of image models for the admin model picker.
* No API discovery — updated manually when providers add/remove models.
* As of 2026-04-26, registry includes 21 models across Runware (15) and
* OpenRouter (6), ordered cheapest-to-most-expensive within each provider.
* Who calls it: PRAutoBlogger_Admin_Page (model picker UI + save-time provider
* derivation) and PRAutoBlogger_Image_Pipeline (provider lookup).
* Dependencies: None.
@@ -34,15 +32,10 @@ class PRAutoBlogger_Image_Model_Registry {
* Ordered cheapest-to-most-expensive within each provider, with the
* recommended default (Runware schnell) at the top.
*
* Last verified: 2026-04-26. To update: check https://runware.ai/models (text-to-image section)
* and https://openrouter.ai/api/v1/models filtering output_modalities containing 'image'.
* Exclude: inpainting (needs mask), img2img (needs seedImage), video, upscalers, background removal.
*
* @return array<int, array<string, mixed>>
*/
public static function get_models(): array {
return array(
// RUNWARE MODELS
array(
'id' => 'runware:100@1',
'name' => 'FLUX.1 schnell (Runware)',
@@ -51,122 +44,17 @@ class PRAutoBlogger_Image_Model_Registry {
'capabilities' => array( 'image_generation' ),
'description' => __( 'Default. Fast, very cheap, comic-friendly fidelity.', 'prautoblogger' ),
),
array(
'id' => 'runware:twinflow-z-image-turbo@0',
'name' => 'TwinFlow Z-Image-Turbo (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Fast text-to-image, same cost as schnell.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@2',
'name' => 'FLUX.2 klein 9B (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.00078,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 compact 9B. Stronger than schnell at similar cost.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@4',
'name' => 'FLUX.2 klein 4B (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0006,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 compact 4B. Newer architecture at schnell price.', 'prautoblogger' ),
),
array(
'id' => 'runware:5@1',
'name' => 'Stable Diffusion 3 (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0013,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Stable Diffusion 3. Sharp text, complex scenes.', 'prautoblogger' ),
),
array(
'id' => 'runware:z-image@turbo',
'name' => 'Z-Image Turbo (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0013,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Fast photorealistic generation.', 'prautoblogger' ),
),
array(
'id' => 'runware:101@1',
'name' => 'FLUX.1 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Higher fidelity FLUX.1. ~30x schnell cost.', 'prautoblogger' ),
'description' => __( 'Higher fidelity FLUX. ~30x schnell cost.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@3',
'name' => 'HiDream-I1 Fast (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0038,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Fast. Low latency, 17B quality.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@2',
'name' => 'HiDream-I1 Dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0045,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Dev. Strong prompt alignment.', 'prautoblogger' ),
),
array(
'id' => 'runware:z-image@0',
'name' => 'Z-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0045,
'capabilities' => array( 'image_generation' ),
'description' => __( 'High-quality Z-Image foundation model.', 'prautoblogger' ),
),
array(
'id' => 'runware:400@1',
'name' => 'FLUX.2 dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0051,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.2 dev. Controllable open text-to-image. From $0.0051.', 'prautoblogger' ),
),
array(
'id' => 'runware:108@1',
'name' => 'Qwen-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0058,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Alibaba Qwen-Image. Strong text rendering in generated images.', 'prautoblogger' ),
),
array(
'id' => 'runware:97@1',
'name' => 'HiDream-I1 Full (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.009,
'capabilities' => array( 'image_generation' ),
'description' => __( 'HiDream-I1 Full. 17B high-fidelity, LoRA support.', 'prautoblogger' ),
),
array(
'id' => 'runware:107@1',
'name' => 'FLUX.1 Krea dev (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0098,
'capabilities' => array( 'image_generation' ),
'description' => __( 'FLUX.1 Krea Dev. Photorealistic open-weight generation.', 'prautoblogger' ),
),
array(
'id' => 'runware:glm-image@0',
'name' => 'GLM-Image (Runware)',
'provider' => 'runware',
'cost_per_image' => 0.0225,
'capabilities' => array( 'image_generation' ),
'description' => __( 'GLM-Image. Hybrid autoregressive+diffusion, excellent text rendering.', 'prautoblogger' ),
),
// OPENROUTER MODELS
array(
'id' => 'google/gemini-2.5-flash-image',
'name' => 'Gemini 2.5 Flash Image (OpenRouter)',
'name' => 'Gemini 2.5 Flash Image (Nano Banana)',
'provider' => 'openrouter',
'cost_per_image' => 0.005,
'capabilities' => array( 'image_generation' ),
@@ -174,39 +62,31 @@ class PRAutoBlogger_Image_Model_Registry {
),
array(
'id' => 'google/gemini-3.1-flash-image-preview',
'name' => 'Gemini 3.1 Flash Image (OpenRouter)',
'name' => 'Gemini 3.1 Flash Image (Nano Banana 2)',
'provider' => 'openrouter',
'cost_per_image' => 0.008,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Latest Google Flash image. Better quality.', 'prautoblogger' ),
'description' => __( 'Latest Google. Better quality.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (Nano Banana Pro)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Highest quality Google. Premium.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image-mini',
'name' => 'GPT-5 Image Mini (OpenRouter)',
'name' => 'GPT-5 Image Mini',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI budget image model.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5.4-image-2',
'name' => 'GPT-5.4 Image 2 (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.02,
'capabilities' => array( 'image_generation' ),
'description' => __( 'OpenAI GPT-5.4 image generation.', 'prautoblogger' ),
),
array(
'id' => 'google/gemini-3-pro-image-preview',
'name' => 'Gemini 3 Pro Image (OpenRouter)',
'provider' => 'openrouter',
'cost_per_image' => 0.03,
'capabilities' => array( 'image_generation' ),
'description' => __( 'Highest quality Google image. Premium.', 'prautoblogger' ),
),
array(
'id' => 'openai/gpt-5-image',
'name' => 'GPT-5 Image (OpenRouter)',
'name' => 'GPT-5 Image',
'provider' => 'openrouter',
'cost_per_image' => 0.08,
'capabilities' => array( 'image_generation' ),

View File

@@ -111,15 +111,10 @@ class PRAutoBlogger_Post_List_Columns {
return;
}
echo '<style>'
. '.wp-list-table .column-title{width:22%}'
. '.column-author{width:6%}'
. '.column-categories{width:7%}'
. '.column-tags{width:9%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}'
. '.column-date{width:8%}'
. '.column-prab_writing_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_image_model{width:85px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_cost{width:50px;white-space:nowrap;overflow:visible}'
. '.column-taxonomy-peptide_topic{width:75px}'
. '.wp-list-table .column-title{width:35%;word-wrap:break-word;overflow-wrap:break-word}'
. '.column-prab_writing_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_image_model{width:100px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}'
. '.column-prab_cost{width:55px;white-space:nowrap;overflow:visible}'
. '.prab-cost-wrap{position:relative;display:inline-block}'
. '.prab-cost-trigger{font-size:12px;color:#2271b1;text-decoration:underline;cursor:pointer}'
. '.prab-cost-popover{display:none;position:absolute;right:0;top:100%;z-index:9999;'
@@ -187,9 +182,8 @@ class PRAutoBlogger_Post_List_Columns {
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$rows = $wpdb->get_results(
$wpdb->prepare(
// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
"SELECT stage, model, prompt_tokens, completion_tokens, estimated_cost // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
FROM {$table} WHERE post_id = %d ORDER BY id ASC",
$post_id
),
ARRAY_A

View File

@@ -9,7 +9,7 @@
* Plugin Name: PRAutoBlogger
* Plugin URI: https://peptiderepo.com/prautoblogger
* Description: Monitors social media for trending topics, generates SEO-friendly blog posts using AI, and publishes them on a daily schedule with full cost tracking and self-improvement metrics.
* Version: 0.13.8
* Version: 0.13.0
* Requires at least: 6.0
* Requires PHP: 7.4
* Author: PeptideRepo
@@ -35,7 +35,7 @@ if ( ! defined( 'ABSPATH' ) ) {
| Defined here so every file in the plugin can reference paths, versions,
| and limits without magic strings.
*/
define( 'PRAUTOBLOGGER_VERSION', '0.13.8' );
define( 'PRAUTOBLOGGER_VERSION', '0.13.0' );
define( 'PRAUTOBLOGGER_DB_VERSION', '1.1.0' );
define( 'PRAUTOBLOGGER_PLUGIN_FILE', __FILE__ );
define( 'PRAUTOBLOGGER_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );

View File

@@ -99,56 +99,4 @@ class ImageModelRegistryTest extends BaseTestCase {
\PRAutoBlogger_Image_Model_Registry::provider_for( '' )
);
}
/**
* Test that runware:400@4 resolves to runware provider.
*/
public function test_provider_for_runware_400_at_4(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:400@4' )
);
}
/**
* Test that runware:glm-image@0 resolves to runware provider.
*/
public function test_provider_for_runware_glm_image(): void {
$this->assertSame(
'runware',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'runware:glm-image@0' )
);
}
/**
* Test that openai/gpt-5.4-image-2 resolves to openrouter provider.
*/
public function test_provider_for_gpt_5_4_image_2(): void {
$this->assertSame(
'openrouter',
\PRAutoBlogger_Image_Model_Registry::provider_for( 'openai/gpt-5.4-image-2' )
);
}
/**
* Test that get_models() returns 21 models.
*/
public function test_get_models_returns_21_models(): void {
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
$this->assertCount( 21, $models );
}
/**
* Test that every model has required non-empty fields.
*/
public function test_every_model_has_required_fields(): void {
$models = \PRAutoBlogger_Image_Model_Registry::get_models();
foreach ( $models as $model ) {
$this->assertNotEmpty( $model['id'] ?? '', 'Model missing id' );
$this->assertNotEmpty( $model['name'] ?? '', 'Model missing name' );
$this->assertNotEmpty( $model['provider'] ?? '', 'Model missing provider' );
$this->assertTrue( isset( $model['cost_per_image'] ), 'Model missing cost_per_image' );
$this->assertIsNumeric( $model['cost_per_image'], 'cost_per_image is not numeric' );
}
}
}