diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index d6208a1..3580a51 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -866,6 +866,35 @@ Kept here for history. Until v0.8.2 the default image backend was FLUX.1 schnell ### #15: Optional Cloudflare AI Gateway in front of OpenRouter We already use Cloudflare for DNS/CDN on peptiderepo.com, so layering AI Gateway in front of OpenRouter is zero marginal infrastructure. It gives us response caching (meaningful for repeated classification/scoring calls), a unified cost/latency dashboard, rate limiting, and provider fallback — all of which we would otherwise have to build ourselves to satisfy the CTO cost-tracking rules. Kept as an opt-in URL setting (`prautoblogger_ai_gateway_base_url`) so the plugin still works unchanged out of the box and can be bypassed instantly if the gateway misbehaves. The gateway is a transparent OpenRouter-compatible proxy; no new provider class is needed, and the response parsing path (`usage`, `choices[0].message.content`) is unchanged. +### #25: Pipeline Settings page — per-step model, prompts, params (v0.23.0, M1) + +**Scope (M1 — additive):** A new "Pipeline" wp-admin submenu page +(`prautoblogger-pipeline`, slug `prautoblogger_page_prautoblogger-pipeline`) that +surfaces per-step configuration for every LLM stage. The page is ADDITIVE — the +existing Settings sections (AI Models, Content, Sources) remain in place. Decomposition +into stage-exclusive panels is M2. + +**Classes:** `PRAutoBlogger_Pipeline_Settings_Page` (registration + asset enqueue), +`PRAutoBlogger_Pipeline_Settings_Renderer` (view data assembly + template include), +`PRAutoBlogger_Pipeline_Settings_Save_Handler` (nonce + capability + sanitize + write), +`PRAutoBlogger_Pipeline_Settings_Step_Map` (canonical step definitions + key allowlists). + +**Model picker reuse:** `PRAutoBlogger_OpenRouter_Model_Field::render()` is called +directly — no fork, no new dropdown. The stage map in `get_stages_for_setting()` was +extended to include `prautoblogger_research_model → [research, llm_research]` and +`prautoblogger_image_model → [image_a, image_b]` so cost-preview is stage-accurate. + +**Prompt writes:** Saving a prompt creates a new immutable row in `wp_prautoblogger_prompts` +via `PRAutoBlogger_Prompt_Registry_Writer::create_version()` and activates it. +Only the keys listed in `PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys()` +may be updated through this page (allowlist enforced in the save handler). + +**Security:** `manage_options` capability check + nonce verification on every POST. +Prompt bodies are sanitized with `sanitize_textarea_field()`. + +**No new options:** M1 adds no persistent `wp_option` keys. Uninstall unchanged. + + --- ### #22: Pipeline v2 Phase 1 substrate (v0.18.0, db 1.2.0, June 2026) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3416c14..9d3491b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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.23.0] - 2026-06-22 + +### Added +- **Pipeline Settings (M1 — additive):** New "Pipeline" wp-admin submenu page + (`prautoblogger-pipeline`) with a step rail covering all LLM stages: Research, + Analysis, Writer, Editorial, Image. Per-step panels expose the existing model + picker (`PRAutoBlogger_OpenRouter_Model_Field`), system-instruction editor, and + agent-prompt editor(s) — all bound to the live versioned prompt registry + (`PRAutoBlogger_Prompt_Registry`). Saving a prompt creates a new immutable version; + reset-to-default available. Active version shown per panel. Writer panel marks + inactive sub-stage prompts. No existing Settings sections changed in M1 (both + surfaces edit the same options / prompts table). +- **`get_stages_for_setting()` extended:** `prautoblogger_research_model → + [research, llm_research]` and `prautoblogger_image_model → [image_a, image_b]` + added so cost-preview on the Pipeline page reflects stage-accurate historical + token usage. +- New files: `includes/admin/class-pipeline-settings-page.php`, + `class-pipeline-settings-renderer.php`, `class-pipeline-settings-save-handler.php`, + `class-pipeline-settings-step-map.php`; `assets/css/pipeline-settings.css`; + `assets/js/pipeline-settings.js`; `templates/admin/pipeline-settings-page.php`. + ## [Unreleased - Internal] ### Internal diff --git a/INFORMATION-ARCHITECTURE.md b/INFORMATION-ARCHITECTURE.md index d8f9fc6..e532965 100644 --- a/INFORMATION-ARCHITECTURE.md +++ b/INFORMATION-ARCHITECTURE.md @@ -11,6 +11,7 @@ Maintained per DoD v1.1.0 (same-PR update rule). |------|-------|----------|---------| | `prautoblogger-settings` | `PRAutoBlogger_Admin_Page` | `admin-page.php` | Main settings (schedule, models, budget, board limits) | | `prautoblogger-board` | `PRAutoBlogger_Board_Page` | `board-page.php` | Kanban board: Generating / Failed / In Review / Published columns. New Article button (v0.21.0). | +| `prautoblogger-pipeline` | `PRAutoBlogger_Pipeline_Settings_Page` | `pipeline-settings-page.php` | Per-step pipeline config: model picker, system instructions, agent prompts, params (M1, v0.23.0). | | `prautoblogger-articles` | `PRAutoBlogger_Articles_Page` | `articles-page.php` | Paginated list of all articles/runs | | `prautoblogger-dossier` | `PRAutoBlogger_Dossier_Page` | `dossier-page.php` | Per-article generation log + stage edit/re-run (M3) | | `prautoblogger-ideas` | `PRAutoBlogger_Ideas_Browser` | `ideas-browser.php` | Analysis results browser with per-idea generation | diff --git a/assets/css/pipeline-settings.css b/assets/css/pipeline-settings.css new file mode 100644 index 0000000..02d4b88 --- /dev/null +++ b/assets/css/pipeline-settings.css @@ -0,0 +1,181 @@ +/** + * Pipeline Settings page styles. + * + * Design language: light mode, teal #1B8A92 accent, IBM Plex Mono for + * numerics/code, wp-admin card shadow pattern. Extends admin.css. + * + * @see admin/class-pipeline-settings-page.php — Enqueues this file. + */ + +/* ── Layout ── */ +.pab-pipeline-wrap { max-width: 1200px; } + +.pab-pipeline-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 0; + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 6px; + box-shadow: 0 1px 4px rgba(0,0,0,.06); + margin-top: 16px; + overflow: hidden; +} + +/* ── Step rail ── */ +.pab-step-rail { + background: #f7f9fa; + border-right: 1px solid #e0e0e0; + padding: 8px 0; +} + +.pab-step-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + text-decoration: none; + color: #3c434a; + font-size: 13px; + border-left: 3px solid transparent; + transition: background .12s, border-color .12s; +} +.pab-step-btn:hover { + background: #edf5f6; + color: #1B8A92; + border-left-color: #a8d8db; +} +.pab-step-btn--active { + background: #e6f4f5; + color: #1B8A92; + font-weight: 600; + border-left-color: #1B8A92; +} +.pab-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + background: #1B8A92; + color: #fff; + border-radius: 50%; + font-size: 11px; + font-family: 'IBM Plex Mono', 'Courier New', monospace; + flex-shrink: 0; +} +.pab-step-btn:not(.pab-step-btn--active) .pab-step-num { + background: #c3cdd4; +} +.pab-step-icon { color: inherit; font-size: 16px !important; flex-shrink: 0; } +.pab-step-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + +/* ── Step panel ── */ +.pab-step-panel { + padding: 24px 28px; + overflow-y: auto; +} + +.pab-panel-header { margin-bottom: 20px; border-bottom: 1px solid #e8ecef; padding-bottom: 14px; } +.pab-panel-header h2 { margin: 0 0 4px; font-size: 18px; color: #1d2327; } +.pab-panel-desc { margin: 4px 0 0; color: #646970; font-size: 13px; } + +/* ── Sections ── */ +.pab-section { margin-bottom: 28px; } +.pab-section:not(:last-child) { border-bottom: 1px solid #f0f0f1; padding-bottom: 24px; } +.pab-section-title { + font-size: 14px; + font-weight: 600; + color: #1d2327; + margin: 0 0 10px; + display: flex; + align-items: center; + gap: 8px; +} + +/* ── Model picker section ── */ +.pab-model-picker-wrap { margin-bottom: 10px; } +.pab-model-form { max-width: 520px; } + +/* ── Prompt editors ── */ +.pab-prompt-editor { + width: 100%; + font-family: 'IBM Plex Mono', 'Courier New', monospace; + font-size: 12px; + line-height: 1.6; + background: #fafbfc; + border: 1px solid #d0d5dd; + border-radius: 4px; + padding: 10px 12px; + resize: vertical; + box-sizing: border-box; + color: #1d2327; +} +.pab-prompt-editor:focus { + border-color: #1B8A92; + box-shadow: 0 0 0 2px rgba(27,138,146,.12); + outline: none; +} +.pab-prompt-form { max-width: 820px; } +.pab-prompt-actions { display: flex; gap: 8px; margin-top: 8px; align-items: center; } +.pab-btn-reset { color: #9b1c1c; border-color: #f5c6c6; } +.pab-btn-reset:hover { background: #fef2f2; border-color: #e53e3e; color: #9b1c1c; } + +/* ── Params table ── */ +.pab-params-table { border-collapse: collapse; font-size: 13px; } +.pab-params-table th, .pab-params-table td { text-align: left; padding: 4px 12px 4px 0; vertical-align: top; } +.pab-params-table th { color: #646970; font-weight: 500; min-width: 140px; } +.pab-mono { + font-family: 'IBM Plex Mono', 'Courier New', monospace; + font-size: 12px; + background: #f3f4f6; + padding: 2px 6px; + border-radius: 3px; +} + +/* ── Badges ── */ +.pab-version-badge { + display: inline-flex; + align-items: center; + background: #e6f4f5; + color: #1B8A92; + font-size: 11px; + font-family: 'IBM Plex Mono', 'Courier New', monospace; + font-weight: 600; + padding: 1px 6px; + border-radius: 10px; + border: 1px solid #b2e0e3; +} +.pab-badge { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; } +.pab-badge-ok { background: #ecfdf5; color: #065f46; border: 1px solid #a7f3d0; } +.pab-badge-warn { background: #fffbeb; color: #92400e; border: 1px solid #fcd34d; } + +/* ── Global context note ── */ +.pab-global-context-note { + display: flex; + align-items: center; + gap: 8px; + background: #f0f9fa; + border: 1px solid #b2e0e3; + border-radius: 4px; + padding: 8px 14px; + margin: 12px 0 0; + font-size: 13px; + color: #1d4f52; +} + +/* ── Misc ── */ +.pab-field-note { color: #646970; font-size: 12px; margin: 6px 0 10px; } +.pab-version-meta { color: #646970; font-size: 12px; margin: -4px 0 8px; } +.pab-inactive-note { + display: flex; + align-items: center; + gap: 6px; + color: #a18a2f; + background: #fffde7; + border: 1px solid #ffe082; + border-radius: 4px; + padding: 6px 12px; + font-size: 12px; + margin-bottom: 8px; +} diff --git a/assets/js/pipeline-settings.js b/assets/js/pipeline-settings.js new file mode 100644 index 0000000..8476d92 --- /dev/null +++ b/assets/js/pipeline-settings.js @@ -0,0 +1,34 @@ +/** + * Pipeline Settings page — step rail and prompt editor interactions. + * + * Responsibilities: + * - Confirm before resetting a prompt to default (redundant safety; the + * PHP onclick already handles it, but JS enhances UX). + * - Wire the model-picker hidden input so the save_model form picks up + * the newly selected model id from the popup. + * + * @see admin/class-pipeline-settings-page.php — Enqueues this script. + * @see assets/js/model-picker.js — Handles the picker popup. + */ +/* global jQuery */ +( function ( $ ) { + 'use strict'; + + $( function () { + + // When the model picker JS selects a model, update the hidden + // 'model_id' input in the same .pab-model-form so the save form + // carries the chosen value on submit. + $( document ).on( 'prautoblogger:model_selected', function ( e, data ) { + var $form = $( '.pab-model-form' ); + var $hidden = $form.find( 'input[name="model_id"]' ); + if ( 0 === $hidden.length ) { + $hidden = $( '' ); + $form.append( $hidden ); + } + $hidden.val( data.model_id || data.id || '' ); + } ); + + } ); + +}( jQuery ) ); diff --git a/includes/admin/class-pipeline-settings-page.php b/includes/admin/class-pipeline-settings-page.php new file mode 100644 index 0000000..86f9768 --- /dev/null +++ b/includes/admin/class-pipeline-settings-page.php @@ -0,0 +1,112 @@ + admin_url( 'admin-ajax.php' ), + 'adminUrl' => admin_url(), + 'siteUrl' => home_url( '/' ), + 'modelsNonce' => wp_create_nonce( 'prautoblogger_get_models' ), + 'imageModels' => PRAutoBlogger_Settings_Fields_Extended::get_image_models(), + ) + ); + } + + /** + * Render the Pipeline Settings page. + * + * Capability check first; then delegate to the save handler (processes + * POST before output starts) and the renderer (assembles view data and + * includes the template). + * + * @return void + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( esc_html__( 'You do not have permission to access this page.', 'prautoblogger' ) ); + } + + // Process any pending save before output begins. + $save_result = PRAutoBlogger_Pipeline_Settings_Save_Handler::maybe_process_save(); + + $renderer = new PRAutoBlogger_Pipeline_Settings_Renderer(); + $renderer->render( $save_result ); + } +} diff --git a/includes/admin/class-pipeline-settings-renderer.php b/includes/admin/class-pipeline-settings-renderer.php new file mode 100644 index 0000000..99e1d65 --- /dev/null +++ b/includes/admin/class-pipeline-settings-renderer.php @@ -0,0 +1,139 @@ + $steps, + 'active_step' => $step, + 'save_result' => $save_result, + 'nonce_field' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD, + 'nonce_action' => PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION, + 'page_slug' => PRAutoBlogger_Pipeline_Settings_Page::PAGE_SLUG, + 'step_data' => $this->build_step_data( $step ), + ); + + include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-page.php'; + } + + /** + * Assemble all data needed to render a single step panel. + * + * @param array $step Step definition from Step_Map::steps(). + * @return array View data for the step panel. + */ + public function build_step_data( array $step ): array { + $defs = PRAutoBlogger_Prompt_Registry::defs(); + $all_versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $step['system_key'] ?? '' ); + $active_versions_map = PRAutoBlogger_Prompt_Registry::active_versions(); + + // Model info. + $model_option = (string) ( $step['model_option'] ?? '' ); + $model_value = '' !== $model_option ? (string) get_option( $model_option, '' ) : ''; + + // System prompt. + $system_key = (string) ( $step['system_key'] ?? '' ); + $system_data = $this->build_prompt_panel_data( $system_key, $defs, $active_versions_map ); + + // Agent/user prompts. + $agent_panels = array(); + foreach ( ( $step['agent_keys'] ?? array() ) as $key ) { + $agent_panels[ $key ] = $this->build_prompt_panel_data( $key, $defs, $active_versions_map ); + } + + // Params from the def for the system key (canonical params are on system). + $params = isset( $defs[ $system_key ] ) ? ( $defs[ $system_key ]['params'] ?? array() ) : array(); + + return array( + 'step' => $step, + 'model_option' => $model_option, + 'model_value' => $model_value, + 'system' => $system_data, + 'agent_panels' => $agent_panels, + 'params' => $params, + ); + } + + /** + * Build display data for a single prompt key's edit panel. + * + * @param string $key Registry key. + * @param array}> $defs All defs. + * @param array $active_versions_map key => active version. + * @return array + */ + private function build_prompt_panel_data( string $key, array $defs, array $active_versions_map ): array { + if ( '' === $key ) { + return array(); + } + + $active_row = PRAutoBlogger_Prompt_Registry::get_active( $key ); + $active_version = $active_versions_map[ $key ] ?? 0; + $body = null !== $active_row ? (string) $active_row['body'] : (string) PRAutoBlogger_Prompt_Registry::default_body( $key ); + $default_body = (string) PRAutoBlogger_Prompt_Registry::default_body( $key ); + $is_default = ( $body === $default_body ); + $created_at = null !== $active_row ? (string) $active_row['created_at'] : ''; + $author = null !== $active_row ? (string) $active_row['author'] : 'seed'; + + $versions = PRAutoBlogger_Prompt_Registry_Writer::list_versions( $key ); + + return array( + 'key' => $key, + 'body' => $body, + 'default_body' => $default_body, + 'is_default' => $is_default, + 'active_version' => $active_version, + 'created_at' => $created_at, + 'author' => $author, + 'version_count' => count( $versions ), + ); + } + + /** + * Render the model picker for a step using the existing field component. + * + * Public so the template can call it directly. Keeps markup generation + * in one place and avoids duplicating the picker rendering logic. + * + * @param string $model_option wp_option name (e.g. 'prautoblogger_writing_model'). + * @param string $model_value Current model id stored in the option. + * @param array $field_args Field args forwarded to the picker. + * @return void Side effect: outputs HTML. + */ + public function render_model_picker( string $model_option, string $model_value, array $field_args ): void { + PRAutoBlogger_OpenRouter_Model_Field::render( $model_option, $model_value, $field_args ); + } +} diff --git a/includes/admin/class-pipeline-settings-save-handler.php b/includes/admin/class-pipeline-settings-save-handler.php new file mode 100644 index 0000000..538d48f --- /dev/null +++ b/includes/admin/class-pipeline-settings-save-handler.php @@ -0,0 +1,185 @@ + 'idle', 'message' => '' ); + + if ( 'POST' !== $_SERVER['REQUEST_METHOD'] ) { + return $idle; + } + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( empty( $_POST[ PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD ] ) ) { + return $idle; + } + + // Capability check. + if ( ! current_user_can( 'manage_options' ) ) { + return array( 'status' => 'error', 'message' => __( 'You do not have permission to save settings.', 'prautoblogger' ) ); + } + + // Nonce verification. + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + if ( ! wp_verify_nonce( wp_unslash( $_POST[ PRAutoBlogger_Pipeline_Settings_Page::NONCE_FIELD ] ), PRAutoBlogger_Pipeline_Settings_Page::NONCE_ACTION ) ) { + return array( 'status' => 'error', 'message' => __( 'Security check failed. Please reload and try again.', 'prautoblogger' ) ); + } + + $action = isset( $_POST['pipeline_action'] ) ? sanitize_key( $_POST['pipeline_action'] ) : ''; + + if ( 'save_model' === $action ) { + return self::handle_model_save(); + } + if ( 'save_prompt' === $action || 'reset_prompt' === $action ) { + return self::handle_prompt_save( $action ); + } + + return $idle; + } + + /** + * Persist a model selection for one step. + * + * Only the allowlisted model option names from Step_Map may be updated. + * + * @return array{status: string, message: string} + */ + private static function handle_model_save(): array { + $option_name = isset( $_POST['model_option'] ) ? sanitize_key( $_POST['model_option'] ) : ''; + $model_id = isset( $_POST['model_id'] ) ? sanitize_text_field( wp_unslash( $_POST['model_id'] ) ) : ''; + + if ( ! in_array( $option_name, PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_model_options(), true ) ) { + return array( 'status' => 'error', 'message' => __( 'Unknown model option.', 'prautoblogger' ) ); + } + + // For the image model, delegate to the same sanitizer logic as + // PRAutoBlogger_Settings_Sanitizer to derive + store provider consistently. + if ( 'prautoblogger_image_model' === $option_name ) { + $candidate = sanitize_text_field( $model_id ); + $provider = PRAutoBlogger_Image_Model_Registry::provider_for( $candidate ); + if ( '' !== $provider ) { + update_option( 'prautoblogger_image_provider', $provider ); + update_option( $option_name, $candidate ); + return array( 'status' => 'saved', 'message' => __( 'Model saved.', 'prautoblogger' ) ); + } + return array( 'status' => 'error', 'message' => __( 'Image model not recognised. Selection unchanged.', 'prautoblogger' ) ); + } + + update_option( $option_name, sanitize_text_field( $model_id ) ); + return array( 'status' => 'saved', 'message' => __( 'Model saved.', 'prautoblogger' ) ); + } + + /** + * Create a new prompt version (or reset to default) in the registry. + * + * The prompt key is received as a slug (dots replaced with hyphens in the + * form value). We match the slug against the allowlist by resolving each + * allowed key to its slug form — this avoids any reverse-parsing ambiguity + * for keys that contain underscores (e.g. content.single_pass). + * + * Prompt bodies are treated as privileged — only sanitize_textarea_field() + * is applied to strip any script injection, but content is not further + * mangled — the operator is intentionally writing LLM instructions. + * + * @param string $action 'save_prompt' or 'reset_prompt'. + * @return array{status: string, message: string} + */ + private static function handle_prompt_save( string $action ): array { + // The form sends the key with dots replaced by hyphens (HTML-safe slug). + $slug = isset( $_POST['prompt_key'] ) ? sanitize_key( wp_unslash( $_POST['prompt_key'] ) ) : ''; + + // Resolve slug back to the real registry key via allowlist lookup. + $prompt_key = self::resolve_key_from_slug( $slug ); + if ( null === $prompt_key ) { + return array( 'status' => 'error', 'message' => __( 'Unknown prompt key.', 'prautoblogger' ) ); + } + + if ( 'reset_prompt' === $action ) { + $body = PRAutoBlogger_Prompt_Registry::default_body( $prompt_key ); + if ( null === $body ) { + return array( 'status' => 'error', 'message' => __( 'No default found for this prompt key.', 'prautoblogger' ) ); + } + $author = 'reset:pipeline-ui'; + } else { + $raw_body = isset( $_POST['prompt_body'] ) ? wp_unslash( $_POST['prompt_body'] ) : ''; + $body = sanitize_textarea_field( $raw_body ); + $author = 'pipeline-ui:' . ( wp_get_current_user()->user_login ?? 'admin' ); + } + + $version = PRAutoBlogger_Prompt_Registry_Writer::create_version( + $prompt_key, + $body, + null, + null, + $author, + true + ); + + if ( 0 === $version ) { + return array( 'status' => 'error', 'message' => __( 'Failed to save prompt. Is the prompts table available?', 'prautoblogger' ) ); + } + + return array( + 'status' => 'saved', + // translators: %1$s = prompt key, %2$d = new version number. + 'message' => sprintf( __( 'Saved %1$s as version %2$d.', 'prautoblogger' ), esc_html( $prompt_key ), $version ), + ); + } + + /** + * Resolve a URL/form slug back to the canonical registry key. + * + * Each allowed key is converted to a slug (dots → hyphens, everything + * lowercased by sanitize_key) and compared against the submitted value. + * No string-reverse parsing — only allowlist membership determines a match. + * + * @param string $slug Slug as received from the form (after sanitize_key). + * @return string|null Canonical key, or null when not in the allowlist. + */ + private static function resolve_key_from_slug( string $slug ): ?string { + foreach ( PRAutoBlogger_Pipeline_Settings_Step_Map::allowed_prompt_keys() as $key ) { + if ( sanitize_key( str_replace( '.', '-', $key ) ) === $slug ) { + return $key; + } + } + return null; + } +} diff --git a/includes/admin/class-pipeline-settings-step-map.php b/includes/admin/class-pipeline-settings-step-map.php new file mode 100644 index 0000000..23c919b --- /dev/null +++ b/includes/admin/class-pipeline-settings-step-map.php @@ -0,0 +1,142 @@ +> + */ + public static function steps(): array { + return array( + array( + 'id' => 'research', + 'label' => __( 'Research', 'prautoblogger' ), + 'icon' => 'dashicons-search', + 'model_option' => 'prautoblogger_research_model', + 'capability' => 'text→text', + 'system_key' => 'research.system', + 'agent_keys' => array(), + 'description' => __( 'LLM deep-research call. The user-side research brief is a setting below, not a registry prompt.', 'prautoblogger' ), + ), + array( + 'id' => 'analysis', + 'label' => __( 'Analysis', 'prautoblogger' ), + 'icon' => 'dashicons-chart-bar', + 'model_option' => 'prautoblogger_analysis_model', + 'capability' => 'text→text', + 'system_key' => 'analysis.system', + 'agent_keys' => array( 'analysis.user' ), + 'description' => __( 'Scores and selects ideas from research findings.', 'prautoblogger' ), + ), + array( + 'id' => 'writer', + 'label' => __( 'Writer', 'prautoblogger' ), + 'icon' => 'dashicons-edit', + 'model_option' => 'prautoblogger_writing_model', + 'capability' => 'text→text', + 'system_key' => 'content.system', + 'agent_keys' => array( 'content.single_pass', 'content.outline', 'content.draft', 'content.polish' ), + 'description' => __( 'Generates article content. Active sub-stages depend on the Writing Pipeline mode setting.', 'prautoblogger' ), + ), + array( + 'id' => 'editorial', + 'label' => __( 'Editorial', 'prautoblogger' ), + 'icon' => 'dashicons-admin-comments', + 'model_option' => 'prautoblogger_editor_model', + 'capability' => 'text→text', + 'system_key' => 'editor.system', + 'agent_keys' => array( 'editor.review' ), + 'description' => __( 'Single-pass editorial review — approves, revises, or rejects the draft.', 'prautoblogger' ), + ), + array( + 'id' => 'image', + 'label' => __( 'Image', 'prautoblogger' ), + 'icon' => 'dashicons-format-image', + 'model_option' => 'prautoblogger_image_model', + 'capability' => 'image_generation', + 'system_key' => 'image.rewriter_system', + 'agent_keys' => array( 'image.style_template' ), + 'description' => __( 'Image prompt rewriter + diffusion generation. The rewriter LLM uses the Analysis model.', 'prautoblogger' ), + ), + ); + } + + /** + * Return a single step definition by its id, or null when not found. + * + * @param string $step_id Machine identifier of the step. + * @return array|null + */ + public static function find( string $step_id ): ?array { + foreach ( self::steps() as $step ) { + if ( $step['id'] === $step_id ) { + return $step; + } + } + return null; + } + + /** + * All registry keys that the save handler is permitted to update via + * the Pipeline Settings page. Any submitted key not in this list is rejected. + * + * @return string[] + */ + public static function allowed_prompt_keys(): array { + $keys = array(); + foreach ( self::steps() as $step ) { + if ( ! empty( $step['system_key'] ) ) { + $keys[] = $step['system_key']; + } + foreach ( ( $step['agent_keys'] ?? array() ) as $k ) { + $keys[] = $k; + } + } + return array_unique( $keys ); + } + + /** + * All model option names that the save handler may update. + * + * @return string[] + */ + public static function allowed_model_options(): array { + $opts = array(); + foreach ( self::steps() as $step ) { + if ( ! empty( $step['model_option'] ) ) { + $opts[] = $step['model_option']; + } + } + return array_unique( $opts ); + } +} diff --git a/includes/admin/fields/class-open-router-model-field.php b/includes/admin/fields/class-open-router-model-field.php index 7b71b76..3177665 100644 --- a/includes/admin/fields/class-open-router-model-field.php +++ b/includes/admin/fields/class-open-router-model-field.php @@ -141,6 +141,8 @@ class PRAutoBlogger_OpenRouter_Model_Field { 'prautoblogger_analysis_model' => array( 'analysis' ), 'prautoblogger_writing_model' => array( 'outline', 'draft', 'polish' ), 'prautoblogger_editor_model' => array( 'review' ), + 'prautoblogger_research_model' => array( 'research', 'llm_research' ), + 'prautoblogger_image_model' => array( 'image_a', 'image_b' ), ); return $map[ $field_id ] ?? array(); } diff --git a/includes/class-prautoblogger.php b/includes/class-prautoblogger.php index ca74d4f..a945907 100644 --- a/includes/class-prautoblogger.php +++ b/includes/class-prautoblogger.php @@ -72,6 +72,11 @@ class PRAutoBlogger { add_action( 'admin_menu', array( $board_page, 'on_register_menu' ), 11 ); add_action( 'admin_enqueue_scripts', array( $board_page, 'on_enqueue_assets' ) ); + // Pipeline Settings: visible submenu, priority 13 (after dossier). + $pipeline_page = new PRAutoBlogger_Pipeline_Settings_Page(); + add_action( 'admin_menu', array( $pipeline_page, 'on_register_menu' ), 13 ); + add_action( 'admin_enqueue_scripts', array( $pipeline_page, 'on_enqueue_assets' ) ); + // Dossier: link-accessed hidden submenu, priority 12 (after board). $dossier_page = new PRAutoBlogger_Dossier_Page(); add_action( 'admin_menu', array( $dossier_page, 'on_register_menu' ), 12 ); diff --git a/prautoblogger.php b/prautoblogger.php index 335d318..2cd6066 100644 --- a/prautoblogger.php +++ b/prautoblogger.php @@ -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.22.0 + * Version: 0.23.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.22.0' ); +define( 'PRAUTOBLOGGER_VERSION', '0.23.0' ); define( 'PRAUTOBLOGGER_DB_VERSION', '1.4.0' ); define( 'PRAUTOBLOGGER_DEFAULT_RUN_CEILING_USD', 0.50 ); define( 'PRAUTOBLOGGER_DEFAULT_REQUEST_JSON_RETENTION_DAYS', 14 ); diff --git a/templates/admin/pipeline-settings-page.php b/templates/admin/pipeline-settings-page.php new file mode 100644 index 0000000..c821a26 --- /dev/null +++ b/templates/admin/pipeline-settings-page.php @@ -0,0 +1,194 @@ +get_monthly_spend(); +$budget = (float) get_option( 'prautoblogger_monthly_budget_usd', 50.00 ); +?> +
+ +
+
+ +
+

+ v +
+
+
+ + + +
+
+ +
+
+ + $ / $ +
+
+ + + + + + + + +
+
+ + +
+ +
+ +
+ +
+ + + +
+ + + + + + +
+ +
+ + + +
+ +
+

+ +

+ +
+ + +
+

+
+ + + +
+ render_model_picker( + $step_data['model_option'], + $step_data['model_value'], + array( 'capability' => $active_step['capability'] ?? 'text→text' ) + ); + ?> +
+

+ +

+ +
+
+ + + +
+

+ + $param_value ) : ?> + + + + + +
+

+ +

+
+ + + + + $panel ) : + $panel_title = sprintf( + /* translators: %s = registry key e.g. 'content.draft' */ + __( 'Prompt: %s', 'prautoblogger' ), + $key + ); + $step_id = $active_step['id']; + $is_writer = ( 'writer' === $step_id ); + $writing_mode = (string) get_option( 'prautoblogger_writing_pipeline', 'multi_step' ); + include PRAUTOBLOGGER_PLUGIN_DIR . 'templates/admin/pipeline-settings-prompt-panel.php'; + endforeach; ?> + +
+
+
diff --git a/templates/admin/pipeline-settings-prompt-panel.php b/templates/admin/pipeline-settings-prompt-panel.php new file mode 100644 index 0000000..050d32b --- /dev/null +++ b/templates/admin/pipeline-settings-prompt-panel.php @@ -0,0 +1,107 @@ + +
+

+ + + 0 ) { + printf( esc_html__( 'v%d', 'prautoblogger' ), (int) $panel['active_version'] ); + } else { + esc_html_e( 'default', 'prautoblogger' ); + } + ?> + + + + +

+ + 0 ) : ?> +

+ +

+ + + +

+ + +

+ + +
+ + + + + + +
+ + + + +
+
+