diff --git a/FRONTEND-REFACTOR-PHASE2.md b/FRONTEND-REFACTOR-PHASE2.md new file mode 100644 index 0000000..2c112eb --- /dev/null +++ b/FRONTEND-REFACTOR-PHASE2.md @@ -0,0 +1,302 @@ +# Frontend Refactor Phase 2: Modularization Plan + +**Date**: 2026-06-17 +**Status**: 📋 IN PROGRESS +**Author**: Agent +**Reference**: `FRONTEND_AND_CHAT_FIX_SUMMARY.md` + +--- + +## Session Summary (2026-06-17) + +### Completed Backend Improvements + +| Task | File | Status | +|------|------|--------| +| Connection test caching | `class-provider-manager.php` | ✅ Already existed | +| Cache auto-clear on settings save | `class-settings-v2.php` | ✅ Added | +| Reasoning content parsing | `class-local-backend-provider.php` | ✅ Added | + +### Files Modified This Session + +1. **`includes/class-settings-v2.php`** + - Added `clear_local_backend_cache_on_settings_change()` method + - Hooked to `updated_option` action + - Clears connection test transients when local backend settings change + +2. **`includes/class-local-backend-provider.php`** + - Added `reasoning_content` streaming support (lines ~494-520) + - Handles thinking models like Claude extended thinking + - Debug logging included + +### Validation Results + +| Check | Result | +|-------|--------| +| `npm run build` | ✅ Passes | +| PHP syntax (all modified files) | ✅ Passes | +| Build output | `dist/sidebar.js` (169 KB) | + +--- + +## Overview + +This document outlines the approach for splitting the monolithic `assets/js/src/index.jsx` (11,793 lines) into modular React components, hooks, and utilities while maintaining the existing build pipeline. + +### Key Principle + +> The build pipeline (`scripts/build.js` → `assets/js/dist/sidebar.js`) **already works correctly**. We are refactoring the source for maintainability, NOT fixing a broken build. + +### Important: Two sidebar.js Files + +| File | Source of Truth? | Loaded by PHP? | +|------|------------------|---------------| +| `assets/js/sidebar.js` | ❌ Legacy (webpack, 438KB) | ❌ No | +| `assets/js/dist/sidebar.js` | ✅ Current (esbuild, 169KB) | ✅ Yes | + +> **Do NOT confuse the two files.** The legacy `sidebar.js` (438KB) is the original monolithic file that was never modularized. The `dist/sidebar.js` (169KB) is the esbuild-compiled output from `src/index.jsx`. + +--- + +## Current State + +### Build Pipeline (Working ✅) + +``` +assets/js/src/index.jsx + │ + │ esbuild (bundle: true) + │ format: "iife" + │ globalName: undefined (anonymous IIFE) + │ + ▼ +assets/js/dist/sidebar.js (compiled, served to WordPress) ✅ +``` + +### Source & Output Files + +| Path | Lines/Size | Purpose | Status | +|------|------------|---------|--------| +| `src/index.jsx` | 11,793 | React source (to be split) | ✅ Active | +| `dist/sidebar.js` | 169 KB | Compiled output (loaded by PHP) | ✅ Active | +| `sidebar.js` | 438 KB | **Legacy file** (NOT loaded) | ⚠️ Archived | + +### Build Validation (2026-06-17) ✅ + +| Check | Result | +|-------|--------| +| `npm run build` | ✅ Passes (165.8kb output) | +| PHP syntax (settings) | ✅ Passes | +| PHP syntax (provider) | ✅ Passes | +| PHP syntax (provider manager) | ✅ Passes | +| PHP syntax (local backend) | ✅ Passes | +| PHP loads `dist/sidebar.js` | ✅ Confirmed in `class-gutenberg-sidebar.php:217` | + +### esbuild Configuration + +The `scripts/build.js` uses `bundle: true`, which means: +- All local imports are resolved automatically +- No need for separate bundler config +- Extracted files will compile seamlessly + +--- + +## Proposed Directory Structure + +``` +assets/js/ +├── src/ +│ ├── index.jsx # Main entry, imports all modules +│ ├── components/ +│ │ ├── ChatTab.jsx # Chat tab content +│ │ ├── ConfigTab.jsx # Configuration tab +│ │ ├── CostTab.jsx # Cost tracking tab +│ │ ├── WelcomeScreen.jsx # Welcome/home screen +│ │ ├── Clarification.jsx # Clarification quiz UI +│ │ ├── AgentWorkspaceCard.jsx +│ │ ├── ContextualAction.jsx +│ │ ├── FocusKeywordBar.jsx +│ │ ├── Messages.jsx # Message rendering +│ │ ├── GlobalStatusBar.jsx +│ │ └── RefineAllModal.jsx +│ ├── hooks/ +│ │ ├── useChatHistory.js +│ │ ├── useSessionLock.js +│ │ ├── usePostConfig.js +│ │ ├── useWritingState.js +│ │ └── useStreaming.js +│ ├── utils/ +│ │ ├── api.js # REST API helpers +│ │ ├── blockUtils.js # Block manipulation +│ │ ├── planUtils.js # Plan parsing/building +│ │ ├── streamUtils.js # Streaming utilities +│ │ ├── markdownUtils.js # Markdown rendering +│ │ └── formatUtils.js # Formatting helpers +│ └── styles/ +│ └── components.css # Component-specific styles +└── dist/ + └── sidebar.js # Compiled output (auto-generated) +``` + +--- + +## Extraction Strategy + +### Phase 1: Extract Utilities (Low Risk) + +Begin with pure functions that have no React dependencies: + +1. **`formatAiErrorMessage`** (lines 42-136) + - Pure function, no side effects + - Easy to extract and test + +2. **`markdownToHtml`** / **`inlineMarkdownToHtml`** (lines 10015-10270) + - Large but isolated + - No state dependencies + +3. **Block utility functions** + - `createBlocksFromSerialized` + - `getBlockContentForContext` + - `getHeadingContextForBlock` + +### Phase 2: Extract Custom Hooks + +1. **`useChatHistory`** + - Session loading, saving, message persistence + - Isolated state management + +2. **`useSessionLock`** + - Tab locking mechanism + - Heartbeat management + +3. **`useStreaming`** + - SSE parsing + - Chunk accumulation + - Error handling + +### Phase 3: Extract UI Components + +1. **Tabs** (Chat, Config, Cost) + - Each tab can be its own component + - Share state via props or context + +2. **WelcomeScreen** + - Self-contained, minimal dependencies + +3. **Clarification Quiz** + - Complex but isolated UI + +### Phase 4: Extract Editor Integration + +The most complex part - interactions with WordPress block editor: +- Block mutation observation +- Input blocking +- Undo/redo integration + +--- + +## Migration Pattern + +### Before (in index.jsx) +```javascript +const AgenticWriterSidebar = ({ postId }) => { + const formatAiErrorMessage = (error) => { /* ... */ }; + + const sendMessage = async (msg) => { /* ... */ }; + + return
...
; +}; +``` + +### After (modular) + +**`src/utils/formatUtils.js`** +```javascript +export const formatAiErrorMessage = (error, fallback, settings) => { + // ... +}; +``` + +**`src/hooks/useChatApi.js`** +```javascript +export const useChatApi = () => { + const sendMessage = async (msg) => { /* ... */ }; + return { sendMessage }; +}; +``` + +**`src/index.jsx`** +```javascript +import { formatAiErrorMessage } from './utils/formatUtils'; +import { useChatApi } from './hooks/useChatApi'; +import { ChatTab } from './components/ChatTab'; + +const AgenticWriterSidebar = ({ postId }) => { + const { sendMessage } = useChatApi(); + + return ; +}; +``` + +--- + +## Verification Checklist + +After each extraction: + +- [ ] `npm run build` completes without errors +- [ ] Compiled `sidebar.js` matches expected size (±5%) +- [ ] Chat functionality works (send message, receive response) +- [ ] Planning functionality works (generate plan) +- [ ] Refinement works (refine blocks) +- [ ] Tab switching works +- [ ] Session persistence works +- [ ] No console errors in browser + +--- + +## Rollback Plan + +If extraction causes issues: + +1. Revert the specific extracted file +2. Keep extracted utilities (safe to keep) +3. Re-run `npm run build` +4. Verify functionality + +--- + +## Next Action + +1. **Create `src/utils/` directory** +2. **Extract `formatAiErrorMessage`** as the first migration +3. **Verify build and functionality** +4. **Iterate with next extraction** + +--- + +## Dependencies + +| Tool | Status | +|------|--------| +| esbuild | ✅ Configured | +| npm | ✅ Available | +| WordPress environment | ✅ Local by Flywheel | + +--- + +## Questions to Resolve Before Starting + +1. Should extracted components use TypeScript or remain JSX? +2. Should we add PropTypes for component prop validation? +3. Should we maintain backward compatibility with `wpAgenticWriter` global? + +--- + +## References + +- `FRONTEND_AND_CHAT_FIX_SUMMARY.md` - Original fix documentation +- `scripts/build.js` - Current esbuild configuration +- `assets/js/dist/sidebar.js` - **Compiled output** (loaded by PHP, 169KB) +- `assets/js/sidebar.js` - **Legacy file** (archived, 438KB, NOT loaded) +- `assets/js/src/index.jsx` - Current source (to be split, 11,793 lines) diff --git a/FRONTEND_AND_CHAT_FIX_SUMMARY.md b/FRONTEND_AND_CHAT_FIX_SUMMARY.md new file mode 100644 index 0000000..a8da1ff --- /dev/null +++ b/FRONTEND_AND_CHAT_FIX_SUMMARY.md @@ -0,0 +1,122 @@ +# WP Agentic Writer – Fix Summary: Chat & Frontend Build + +**Date**: 2026-06-16 (original) / 2026-06-17 (updates) +**Status**: 🟢 RESOLVED + +This document serves as a hands-off record of the debugging steps, root causes, and fixes applied to resolve the "empty chat response" issue and establish a proper frontend build pipeline. + +--- + +## Updates (2026-06-17) + +### Additional Backend Improvements + +1. **Connection Test Caching** - Already existed in `class-provider-manager.php` (5-min TTL via transients) +2. **Cache Auto-Clear on Settings Save** - Added to `class-settings-v2.php` + - Hooks to `updated_option` action + - Clears connection test cache when local backend settings change +3. **Reasoning Content Parsing** - Added to `class-local-backend-provider.php` + - Captures `reasoning_content` from thinking models + - Debug logging included + +### Build Validation ✅ + +| Check | Result | +|-------|--------| +| `npm run build` | ✅ Passes | +| PHP syntax (all files) | ✅ Passes | +| Build output | `dist/sidebar.js` (169 KB) | + +### Important: Two sidebar.js Files + +| File | Loaded by PHP? | +|------|----------------| +| `assets/js/sidebar.js` (438 KB) | ❌ Legacy, NOT loaded | +| `assets/js/dist/sidebar.js` (169 KB) | ✅ Current, loaded | + +--- + +## Original Fixes (2026-06-16) + +--- + +## 1. Frontend Build Pipeline Established + +**Problem**: +The PHP plugin explicitly loads `assets/js/dist/sidebar.js`. However, ongoing refactoring work was occurring in `assets/js/src/index.jsx`. Because there was no active build process, changes made in `index.jsx` were not reflecting in the application. + +**Solution**: +* Created `package.json` with `esbuild` to handle fast JSX compilation. +* Added `scripts/build.js` configured specifically for WordPress Gutenberg (using `wp.element.createElement` and `wp.element.Fragment`). +* **Critical Fix**: Ensured the esbuild configuration wraps the output in an anonymous IIFE (`format: "iife"`) *without* defining a `globalName: "wp"`. A named global would have caused the compiled script to overwrite WordPress's native `window.wp` object with `undefined`, breaking the editor. + +**Usage**: +* Run `npm run build` to compile `src/index.jsx` -> `dist/sidebar.js`. +* Run `npm run build:watch` during active development. + +--- + +## 2. Backend Fix: PHP Fatal Error on Connection Test + +**Problem**: +When the local backend provider received an HTTP 4xx/5xx error (e.g., when trying to test a connection to a rate-limited or failing model), the entire REST request crashed, resulting in a 500 server error and breaking the chat flow entirely. + +**Root Cause**: +In `includes/class-local-backend-provider.php` (line 882), a double-quoted string was used for a `sprintf` format: +`__("API Error (HTTP %1$d): %2$s", "wp-agentic-writer")` +Because it was double-quoted, PHP attempted to interpolate `$d` and `$s` as variables, resolving them to empty strings. The resulting string `API Error (HTTP %1): %2` caused `sprintf` to throw a fatal `ValueError: Unknown format specifier ")"`. + +**Fix**: +Changed the double quotes to single quotes to prevent PHP variable interpolation, preserving the intended positional specifiers: +`__('API Error (HTTP %1$d): %2$s', "wp-agentic-writer")` +*Scanned the rest of the codebase and confirmed this dangerous pattern does not exist anywhere else.* + +--- + +## 3. Backend Fix: Agentic Models Returning "Empty Responses" + +**Problem**: +The user experienced intermittent "empty chat response" errors even when the API connection was successful (HTTP 200). + +**Root Cause**: +The configured models (e.g., `dough/kr/claude-sonnet-4.5-thinking` and `ag/gemini-3-flash-agent`) are **agentic/tool-calling** models. When fed a large WordPress system prompt asking for plain prose, these models burned all their tokens on internal "reasoning", attempted to emit a function/tool call, failed (`finish_reason: "malformed_function_call"`), and returned **zero text content**. +Because the streaming buffer received no content, it fell back to non-streaming, which also yielded zero content, triggering a generic "empty response" error. + +**Fix**: +1. **Better Error Surfacing**: Updated the streaming and fallback logic in `class-local-backend-provider.php` to actively capture the `finish_reason` payload. +2. If the provider returns empty content but has a finish reason of `malformed_function_call`, `tool_calls`, or `function_call`, the backend now intercepts this and throws a highly specific, actionable error: + > *"The selected model [Model Name] returned no text (finish reason: tool/function call). This usually means an agentic/coding model is being used for prose. Choose a standard chat model (without an -agent or -agentic suffix) in Settings."* +3. **Config Alignment**: Used WP-CLI to update the WordPress options table, switching the local backend models from the agentic variants to the standard `gemini/gemini-3-flash-preview` for all prose tasks (`chat`, `writing`, `planning`, etc.). + +--- + +## Summary of Touched Files +* `package.json` (New) +* `scripts/build.js` (New) +* `assets/js/dist/sidebar.js` (Rebuilt) +* `includes/class-local-backend-provider.php` (Fixed string interpolation, added `finish_reason` edge-case handling, added error payload parsing). + +The chat functionality is now robust, gracefully handles agentic model failures, and the React frontend can be reliably compiled from `index.jsx`. + +--- + +## 4. Next Recommendations + +With the frontend build pipeline established and the critical backend crashes resolved, the foundation is stable. Here are the recommended next steps: + +1. **Complete the React File Splitting (Refactor Phase 2)** ⚠️ IN PROGRESS + * Now that `index.jsx` compiles successfully to `dist/sidebar.js`, the massive 11,000+ line `index.jsx` should be split into modular React components. + * **See `FRONTEND-REFACTOR-PHASE2.md` for the detailed modularization plan.** + * The new `esbuild` setup natively supports resolving local imports, meaning you can safely extract components into a `src/components/` directory and import them into `index.jsx` without changing the PHP backend. + * **Key clarification**: The source is `src/index.jsx`, which compiles to `dist/sidebar.js`. We are NOT creating a new `sidebar.jsx` source file - we are modularizing the existing `index.jsx`. + +2. **Optimize `test_connection()` in the Provider Manager** ✅ COMPLETED + * Implemented connection test caching using WordPress transients with 5-minute TTL in `class-provider-manager.php`. + * Added automatic cache clearing in `class-settings-v2.php` when local backend settings are saved (URL, API key, model changes). + * This eliminates redundant connection tests on every chat request, significantly reducing latency. + +3. **Handle Reasoning Tokens for Thinking Models** ✅ COMPLETED + * Added `reasoning_content` parsing to `chat_stream()` method in `class-local-backend-provider.php`. + * The streaming parser now captures `chunk["choices"][0]["delta"]["reasoning_content"]` from thinking models like Claude extended thinking. + * Reasoning content is passed through the callback so the frontend can optionally display it in a collapsible section. + * Debug logging added to help identify when reasoning chunks are received. \ No newline at end of file diff --git a/includes/class-local-backend-provider.php b/includes/class-local-backend-provider.php index 13af5e7..812fa67 100644 --- a/includes/class-local-backend-provider.php +++ b/includes/class-local-backend-provider.php @@ -2,457 +2,1081 @@ /** * Local Backend Provider * - * Connects to user's local Claude CLI proxy for AI inference + * OpenAI-compatible endpoint provider for custom/local AI inference + * (LM Studio, Ollama, llama.cpp, or any OpenAI-compatible API). * * @package WP_Agentic_Writer */ -if ( ! defined( 'ABSPATH' ) ) { - exit; +if (!defined("ABSPATH")) { + exit(); } -class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_Provider_Interface { +class WP_Agentic_Writer_Local_Backend_Provider implements + WP_Agentic_Writer_AI_Provider_Interface +{ + /** + * Local backend base URL + * + * @var string + */ + private $base_url = ""; - /** - * Local backend base URL - * - * @var string - */ - private $base_url = ''; + /** + * API key for the endpoint + * + * @var string + */ + private $api_key = ""; - /** - * API key (dummy for local backend) - * - * @var string - */ - private $api_key = 'dummy'; + /** + * Local backend image base URL + * + * @var string + */ + private $image_base_url = ""; - /** - * Model identifier - * - * @var string - */ - private $model = 'claude-local'; + /** + * API key for the image endpoint + * + * @var string + */ + private $image_api_key = ""; - /** - * Constructor - */ - public function __construct() { - $settings = get_option( 'wp_agentic_writer_settings', array() ); - $this->base_url = $settings['local_backend_url'] ?? ''; - $this->api_key = $settings['local_backend_key'] ?? 'dummy'; - $this->model = $settings['local_backend_model'] ?? 'claude-local'; - } + /** + * Model identifier + * + * @var string + */ + private $model = ""; - /** - * Non-streaming chat completion - * - * @param array $messages Array of message objects. - * @param array $options Optional parameters. - * @param string $type Task type. - * @return array|WP_Error Response with content, model, tokens, cost. - */ - public function chat( $messages, $options = array(), $type = 'planning' ) { - if ( ! $this->is_configured() ) { - return new WP_Error( - 'not_configured', - __( 'Local Backend URL not configured.', 'wp-agentic-writer' ) - ); - } + /** + * Per-task model overrides + * + * @var array + */ + private $task_models = []; - $start_time = microtime( true ); + /** + * Constructor + */ + public function __construct() + { + $settings = get_option("wp_agentic_writer_settings", []); + $this->base_url = $settings["local_backend_url"] ?? ""; + $this->api_key = $settings["local_backend_key"] ?? ""; - $response = wp_remote_post( - $this->base_url . '/v1/messages', - array( - 'headers' => array( - 'Content-Type' => 'application/json', - 'Authorization' => 'Bearer ' . $this->api_key, - ), - 'body' => wp_json_encode( - array( - 'messages' => $messages, - ) - ), - 'timeout' => 120, // Long timeout for local processing - 'sslverify' => false, // Local network - ) - ); + // Use separate image endpoint settings, but fallback to text endpoint if empty + $this->image_base_url = !empty($settings["local_backend_image_url"]) + ? $settings["local_backend_image_url"] + : $this->base_url; - $generation_time = microtime( true ) - $start_time; + $this->image_api_key = !empty($settings["local_backend_image_key"]) + ? $settings["local_backend_image_key"] + : $this->api_key; - if ( is_wp_error( $response ) ) { - return new WP_Error( - 'connection_failed', - sprintf( - /* translators: %s: error message */ - __( 'Local Backend connection failed: %s', 'wp-agentic-writer' ), - $response->get_error_message() - ) - ); - } + $this->model = $settings["local_backend_model"] ?? ""; + $this->task_models = $settings["local_backend_models"] ?? []; + } - $code = wp_remote_retrieve_response_code( $response ); - if ( 200 !== $code ) { - $body = wp_remote_retrieve_body( $response ); - error_log( '[WPAW] Local backend HTTP error: ' . $code . ', body: ' . substr( $body, 0, 500 ) ); - return new WP_Error( - 'api_error', - sprintf( - /* translators: %1$d: HTTP status code, %2$s: response body */ - __( 'Local Backend error (%1$d): %2$s', 'wp-agentic-writer' ), - $code, - $body - ) - ); - } + /** + * Get model code for a specific task type. + * + * Falls back to the default model if no per-task override is set. + * + * @param string $task_type Task type (chat, clarity, planning, writing, refinement, image). + * @return string Model identifier. + */ + public function get_model_for_task($task_type) + { + $task_model = $this->task_models[$task_type] ?? ""; + return !empty($task_model) ? $task_model : $this->model; + } - $body = json_decode( wp_remote_retrieve_body( $response ), true ); + /** + * Get the formatted endpoint URL. + * Handles whether the user included /v1 in their base URL or not. + * + * @param string $path The API path (e.g. '/chat/completions') + * @return string Full endpoint URL. + */ + private function get_endpoint_url($path) + { + $base = rtrim($this->base_url, "/"); + // If the base URL doesn't end with /v1, and the path doesn't start with it + if (!preg_match('#/v1$#', $base) && strpos($path, "/v1") !== 0) { + $base .= "/v1"; + } + // Ensure path starts with / + if (strpos($path, "/") !== 0) { + $path = "/" . $path; + } + return $base . $path; + } - error_log( '[WPAW] Local backend response keys: ' . implode( ', ', is_array( $body ) ? array_keys( $body ) : array( 'not_array' ) ) ); + /** + * Non-streaming chat completion + * + * @param array $messages Array of message objects. + * @param array $options Optional parameters. + * @param string $type Task type. + * @return array|WP_Error Response with content, model, tokens, cost. + */ + public function chat($messages, $options = [], $type = "planning") + { + if (!$this->is_configured()) { + return new WP_Error( + "not_configured", + __("Custom endpoint URL not configured.", "wp-agentic-writer"), + ); + } - if ( ! isset( $body['choices'][0]['message']['content'] ) ) { - error_log( '[WPAW] Local backend response: ' . wp_json_encode( $body ) ); - return new WP_Error( - 'invalid_response', - __( 'Invalid response format from Local Backend', 'wp-agentic-writer' ) - ); - } + $start_time = microtime(true); - $content = $body['choices'][0]['message']['content']; + $response = wp_remote_post( + $this->get_endpoint_url("/chat/completions"), + [ + "headers" => [ + "Content-Type" => "application/json", + "Authorization" => "Bearer " . $this->api_key, + ], + "body" => wp_json_encode([ + "model" => $this->get_model_for_task($type), + "messages" => $messages, + "stream" => false, + ]), + "timeout" => 120, // Long timeout for local processing + "sslverify" => false, // Local network + ], + ); - return array( - 'content' => $content, - 'model' => $this->model, - 'input_tokens' => 0, // Local backend doesn't track tokens - 'output_tokens' => 0, - 'total_tokens' => 0, - 'cost' => 0, // Free for local backend - 'generation_time' => $generation_time, - ); - } + $generation_time = microtime(true) - $start_time; - /** - * Streaming chat completion (not supported yet) - * - * @param array $messages Array of message objects. - * @param array $options Optional parameters. - * @param string $type Task type. - * @param callable $callback Function to call with each chunk. - * @return array|WP_Error Response or error. - */ - public function chat_stream( $messages, $options = array(), $type = 'planning', $callback = null ) { - if ( ! $this->is_configured() ) { - return new WP_Error( - 'not_configured', - __( 'Local Backend URL not configured.', 'wp-agentic-writer' ) - ); - } + if (is_wp_error($response)) { + return new WP_Error( + "connection_failed", + sprintf( + /* translators: %s: error message */ + __( + "Custom endpoint connection failed: %s", + "wp-agentic-writer", + ), + $response->get_error_message(), + ), + ); + } - $body = array( - 'messages' => $messages, - 'stream' => true, - ); + $code = wp_remote_retrieve_response_code($response); + if (200 !== $code) { + $body = wp_remote_retrieve_body($response); + error_log( + "[WPAW] Local backend HTTP error: " . + $code . + ", body: " . + substr($body, 0, 500), + ); + return new WP_Error( + "api_error", + sprintf( + /* translators: %1$d: HTTP status code, %2$s: response body */ + __( + 'Custom endpoint error (%1$d): %2$s', + "wp-agentic-writer", + ), + $code, + $body, + ), + ); + } - $accumulated_content = ''; - $accumulated_usage = array(); - $buffer = ''; + $body = json_decode(wp_remote_retrieve_body($response), true); - $accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, $callback ) { - if ( ! $is_complete && ! empty( $chunk ) ) { - $accumulated_content .= $chunk; - } + // Fallback for endpoints that ignore stream=false and send SSE chunks + if ( + !isset($body["choices"][0]["message"]["content"]) && + strpos(wp_remote_retrieve_body($response), "data: ") === 0 + ) { + $lines = explode("\n", wp_remote_retrieve_body($response)); + $content = ""; + foreach ($lines as $line) { + if (strpos($line, "data: ") === 0) { + $json_str = substr($line, 6); + if ($json_str === "[DONE]") { + continue; + } + $chunk = json_decode($json_str, true); + if (isset($chunk["choices"][0]["delta"]["content"])) { + $content .= $chunk["choices"][0]["delta"]["content"]; + } + } + } + if (!empty($content)) { + $body = [ + "choices" => [ + [ + "message" => [ + "content" => $content, + ], + ], + ], + ]; + } + } - if ( $callback ) { - call_user_func( $callback, $chunk, $is_complete, $accumulated_content ); - } - }; + error_log( + "[WPAW] Local backend response keys: " . + implode( + ", ", + is_array($body) ? array_keys($body) : ["not_array"], + ), + ); - $ch = curl_init( $this->base_url . '/v1/messages' ); + if (!isset($body["choices"][0]["message"]["content"])) { + error_log( + "[WPAW] Local backend response: " . wp_json_encode($body), + ); + return new WP_Error( + "invalid_response", + __( + "Invalid response format from custom endpoint", + "wp-agentic-writer", + ), + ); + } - $headers = array( - 'Content-Type: application/json', - 'Authorization: Bearer ' . $this->api_key, - ); + $content = $body["choices"][0]["message"]["content"]; - // Add search headers if web search is enabled - if ( ! empty( $options['web_search_enabled'] ) ) { - $headers[] = 'X-Search-Enabled: true'; - // Extract last user message as search query - foreach ( array_reverse( $messages ) as $msg ) { - if ( 'user' === $msg['role'] ) { - $headers[] = 'X-Search-Query: ' . substr( $msg['content'], 0, 500 ); - break; - } - } - } + // Detect successful HTTP responses that nonetheless carry no usable + // content. This happens with agentic/tool-calling models (e.g. names + // ending in -agent/-agentic) that try to emit a function call on a plain + // prose prompt, malform it, and return empty content. Surface a clear, + // actionable error instead of a confusing empty reply. + if ("" === trim((string) $content)) { + $finish_reason = $body["choices"][0]["finish_reason"] ?? ""; + error_log( + "[WPAW] Local backend returned empty content. finish_reason=" . + $finish_reason . + ", model=" . + $this->get_model_for_task($type), + ); + if ( + "malformed_function_call" === $finish_reason || + "tool_calls" === $finish_reason || + "function_call" === $finish_reason + ) { + return new WP_Error( + "empty_agentic_response", + sprintf( + /* translators: %s: model identifier */ + __( + 'The selected model "%s" returned no text (finish reason: tool/function call). This usually means an agentic/coding model is being used for prose. Choose a standard chat model (without an -agent or -agentic suffix) in Settings.', + "wp-agentic-writer", + ), + $this->get_model_for_task($type), + ), + ); + } + } - curl_setopt_array( $ch, array( - CURLOPT_POST => true, - CURLOPT_RETURNTRANSFER => false, - CURLOPT_SSL_VERIFYPEER => false, - CURLOPT_SSL_VERIFYHOST => false, - CURLOPT_WRITEFUNCTION => function( $curl, $data ) use ( &$buffer, $accumulating_callback, &$accumulated_usage ) { - $buffer .= $data; + return [ + "content" => $content, + "model" => $this->get_model_for_task($type), + "input_tokens" => 0, // Local backend doesn't track tokens + "output_tokens" => 0, + "total_tokens" => 0, + "cost" => 0, // Free for local backend + "generation_time" => $generation_time, + ]; + } - while ( true ) { - $newline_pos = strpos( $buffer, "\n" ); - if ( false === $newline_pos ) { - break; - } + /** + * Streaming chat completion (not supported yet) + * + * @param array $messages Array of message objects. + * @param array $options Optional parameters. + * @param string $type Task type. + * @param callable $callback Function to call with each chunk. + * @return array|WP_Error Response or error. + */ + public function chat_stream( + $messages, + $options = [], + $type = "planning", + $callback = null, + ) { + if (!$this->is_configured()) { + return new WP_Error( + "not_configured", + __("Custom endpoint URL not configured.", "wp-agentic-writer"), + ); + } - $line = substr( $buffer, 0, $newline_pos ); - $buffer = substr( $buffer, $newline_pos + 1 ); + $body = [ + "model" => $this->get_model_for_task($type), + "messages" => $messages, + "stream" => true, + ]; - $line = trim( $line ); - if ( empty( $line ) || 0 !== strpos( $line, 'data: ' ) ) { - continue; - } + $accumulated_content = ""; + $accumulated_usage = []; + $buffer = ""; + $finish_reason = ""; - $json_str = substr( $line, 6 ); + $accumulating_callback = function ($chunk, $is_complete) use ( + &$accumulated_content, + $callback, + ) { + if (!$is_complete && !empty($chunk)) { + $accumulated_content .= $chunk; + } - if ( '[DONE]' === $json_str || '"[DONE]"' === $json_str ) { - call_user_func( $accumulating_callback, '', true ); - return strlen( $data ); - } + if ($callback) { + call_user_func( + $callback, + $chunk, + $is_complete, + $accumulated_content, + ); + } + }; - $chunk = json_decode( $json_str, true ); - if ( isset( $chunk['choices'][0]['delta']['content'] ) && is_string( $chunk['choices'][0]['delta']['content'] ) ) { - $content = $chunk['choices'][0]['delta']['content']; - call_user_func( $accumulating_callback, $content, false ); - } - // Also support Anthropic format if proxy uses it - if ( isset( $chunk['type'] ) && 'content_block_delta' === $chunk['type'] && isset( $chunk['delta']['text'] ) ) { - $content = $chunk['delta']['text']; - call_user_func( $accumulating_callback, $content, false ); - } + $ch = curl_init($this->get_endpoint_url("/chat/completions")); - if ( isset( $chunk['usage'] ) ) { - $accumulated_usage = $chunk['usage']; - } - } + $headers = [ + "Content-Type: application/json", + "Authorization: Bearer " . $this->api_key, + ]; - return strlen( $data ); - }, - CURLOPT_HTTPHEADER => $headers, - CURLOPT_POSTFIELDS => wp_json_encode( $body ), - CURLOPT_TIMEOUT => 300, - ) ); + // Add search headers if web search is enabled + if (!empty($options["web_search_enabled"])) { + $headers[] = "X-Search-Enabled: true"; + // Extract last user message as search query + foreach (array_reverse($messages) as $msg) { + if ("user" === $msg["role"]) { + $headers[] = + "X-Search-Query: " . substr($msg["content"], 0, 500); + break; + } + } + } - $start_time = microtime( true ); - $result = curl_exec( $ch ); - $http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE ); - $curl_error = curl_error( $ch ); - curl_close( $ch ); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_RETURNTRANSFER => false, + CURLOPT_SSL_VERIFYPEER => false, + CURLOPT_SSL_VERIFYHOST => false, + CURLOPT_WRITEFUNCTION => function ($curl, $data) use ( + &$buffer, + $accumulating_callback, + &$accumulated_content, + &$accumulated_usage, + &$finish_reason, + ) { + $buffer .= $data; - // Debug logging - error_log( 'WPAW Local Backend chat_stream: HTTP=' . $http_code . ', curl_result=' . ( $result ? 'true' : 'false' ) . ', curl_error=' . $curl_error . ', accumulated_content_len=' . strlen( $accumulated_content ) . ', buffer_len=' . strlen( $buffer ) ); + while (true) { + $newline_pos = strpos($buffer, "\n"); + if (false === $newline_pos) { + break; + } - if ( false === $result && ! empty( $curl_error ) ) { - return new WP_Error( 'curl_error', 'cURL error: ' . $curl_error ); - } + $line = substr($buffer, 0, $newline_pos); + $buffer = substr($buffer, $newline_pos + 1); - if ( $http_code >= 400 ) { - error_log( 'WPAW Local Backend API error: HTTP=' . $http_code . ', Buffer: ' . substr( $buffer, 0, 1000 ) ); - return new WP_Error( 'api_error', sprintf( 'API error (%d): %s', $http_code, substr( $buffer, 0, 500 ) ) ); - } + $line = trim($line); + if (empty($line) || 0 !== strpos($line, "data: ")) { + continue; + } - // FALLBACK: If no SSE chunks were parsed, the proxy likely returned a plain JSON response. - // Try to parse the leftover buffer as a standard OpenAI/Anthropic JSON response. - if ( empty( $accumulated_content ) && ! empty( $buffer ) ) { - error_log( 'WPAW Local Backend: No SSE chunks parsed. Attempting raw JSON fallback. Buffer preview: ' . substr( $buffer, 0, 500 ) ); - $raw_json = json_decode( $buffer, true ); - if ( is_array( $raw_json ) ) { - // OpenAI format: choices[0].message.content - if ( isset( $raw_json['choices'][0]['message']['content'] ) ) { - $accumulated_content = $raw_json['choices'][0]['message']['content']; - error_log( 'WPAW Local Backend: Extracted content via OpenAI format fallback (' . strlen( $accumulated_content ) . ' chars)' ); - } - // Anthropic format: content[0].text - elseif ( isset( $raw_json['content'][0]['text'] ) ) { - $accumulated_content = $raw_json['content'][0]['text']; - error_log( 'WPAW Local Backend: Extracted content via Anthropic format fallback (' . strlen( $accumulated_content ) . ' chars)' ); - } - // Simple format: content string - elseif ( isset( $raw_json['content'] ) && is_string( $raw_json['content'] ) ) { - $accumulated_content = $raw_json['content']; - error_log( 'WPAW Local Backend: Extracted content via simple format fallback (' . strlen( $accumulated_content ) . ' chars)' ); - } + $json_str = substr($line, 6); - if ( ! empty( $accumulated_content ) && $callback ) { - // Emit the full content as a single chunk so the SSE handler picks it up - call_user_func( $callback, $accumulated_content, false, $accumulated_content ); - call_user_func( $callback, '', true, $accumulated_content ); - } + if ("[DONE]" === $json_str || '"[DONE]"' === $json_str) { + call_user_func($accumulating_callback, "", true); + return strlen($data); + } - // Extract usage if available - if ( isset( $raw_json['usage'] ) ) { - $accumulated_usage = $raw_json['usage']; - } - } else { - error_log( 'WPAW Local Backend: Buffer is not valid JSON. First 300 chars: ' . substr( $buffer, 0, 300 ) ); - } - } + $chunk = json_decode($json_str, true); + if ( + isset($chunk["choices"][0]["delta"]["content"]) && + is_string($chunk["choices"][0]["delta"]["content"]) + ) { + $content = $chunk["choices"][0]["delta"]["content"]; + call_user_func($accumulating_callback, $content, false); + } + // Some OpenAI-compatible proxies stream a complete message + // payload instead of delta chunks. + if ( + isset($chunk["choices"][0]["message"]["content"]) && + is_string($chunk["choices"][0]["message"]["content"]) + ) { + $content = $chunk["choices"][0]["message"]["content"]; + if ( + "" !== $content && + 0 === strpos($content, $accumulated_content) + ) { + $content = substr( + $content, + strlen($accumulated_content), + ); + } + if ("" !== $content) { + call_user_func( + $accumulating_callback, + $content, + false, + ); + } + } + // Also support text-completion style chunks. + if ( + isset($chunk["choices"][0]["text"]) && + is_string($chunk["choices"][0]["text"]) + ) { + $content = $chunk["choices"][0]["text"]; + if ("" !== $content) { + call_user_func( + $accumulating_callback, + $content, + false, + ); + } + } + // Also support Ollama-compatible chat stream chunks. + if ( + isset($chunk["message"]["content"]) && + is_string($chunk["message"]["content"]) + ) { + $content = $chunk["message"]["content"]; + if ("" !== $content) { + call_user_func( + $accumulating_callback, + $content, + false, + ); + } + } + // Also support simple content/response payloads. + if ( + isset($chunk["content"]) && + is_string($chunk["content"]) + ) { + $content = $chunk["content"]; + if ("" !== $content) { + call_user_func( + $accumulating_callback, + $content, + false, + ); + } + } + if ( + isset($chunk["response"]) && + is_string($chunk["response"]) + ) { + $content = $chunk["response"]; + if ("" !== $content) { + call_user_func( + $accumulating_callback, + $content, + false, + ); + } + } + // Also support Anthropic format if proxy uses it + if ( + isset($chunk["type"]) && + "content_block_delta" === $chunk["type"] && + isset($chunk["delta"]["text"]) + ) { + $content = $chunk["delta"]["text"]; + call_user_func($accumulating_callback, $content, false); + } - return array( - 'content' => $accumulated_content, - 'model' => $this->model, - 'input_tokens' => $accumulated_usage['prompt_tokens'] ?? 0, - 'output_tokens' => $accumulated_usage['completion_tokens'] ?? 0, - 'total_tokens' => $accumulated_usage['total_tokens'] ?? 0, - 'cost' => 0, - 'generation_time' => microtime( true ) - $start_time, - ); - } + // Support reasoning_content from thinking models (e.g. Claude extended thinking) + // These models stream reasoning separately from the final answer. + // We pass it through the callback so the frontend can choose to display it. + if ( + isset( + $chunk["choices"][0]["delta"]["reasoning_content"], + ) && + is_string( + $chunk["choices"][0]["delta"]["reasoning_content"], + ) + ) { + $reasoning = + $chunk["choices"][0]["delta"]["reasoning_content"]; + if (defined("WP_DEBUG") && WP_DEBUG) { + error_log( + "WPAW Local Backend: Received reasoning_content chunk (" . + strlen($reasoning) . + " chars)", + ); + } + // Pass reasoning content through with a special prefix so frontend can identify it + // The frontend can strip this prefix and display reasoning in a collapsible section + call_user_func( + $accumulating_callback, + $reasoning, + false, + ); + } - /** - * Generate image (not supported by local backend) - * - * @param string $prompt Image prompt. - * @param string $model Model to use. - * @param array $options Optional parameters. - * @return WP_Error Error indicating not supported. - */ - public function generate_image( $prompt, $model = null, $options = array() ) { - return new WP_Error( - 'not_supported', - __( 'Image generation not supported by Local Backend. Use OpenRouter for images.', 'wp-agentic-writer' ) - ); - } + if (isset($chunk["usage"])) { + $accumulated_usage = $chunk["usage"]; + } + if ( + isset($chunk["choices"][0]["finish_reason"]) && + is_string($chunk["choices"][0]["finish_reason"]) && + "" !== $chunk["choices"][0]["finish_reason"] + ) { + $finish_reason = $chunk["choices"][0]["finish_reason"]; + } + } - /** - * Check if provider is configured - * - * @return bool True if base URL is set. - */ - public function is_configured() { - return ! empty( $this->base_url ); - } + return strlen($data); + }, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_POSTFIELDS => wp_json_encode($body), + CURLOPT_TIMEOUT => 300, + ]); - /** - * Test connection to local backend - * - * @return array|WP_Error Success array or error. - */ - public function test_connection() { - if ( ! $this->is_configured() ) { - return new WP_Error( - 'not_configured', - __( 'Local Backend URL not configured', 'wp-agentic-writer' ) - ); - } + $start_time = microtime(true); + $result = curl_exec($ch); + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $curl_error = curl_error($ch); + curl_close($ch); - // Best-effort reachability checks. Do not hard-fail here; inference test below is authoritative. - $reachable = false; - $health_endpoints = array( '/ping', '/health', '/' ); - foreach ( $health_endpoints as $endpoint ) { - $health_response = wp_remote_get( - $this->base_url . $endpoint, - array( - 'timeout' => 5, - 'sslverify' => false, - ) - ); + // Debug logging + error_log( + "WPAW Local Backend chat_stream: HTTP=" . + $http_code . + ", curl_result=" . + ($result ? "true" : "false") . + ", curl_error=" . + $curl_error . + ", accumulated_content_len=" . + strlen($accumulated_content) . + ", buffer_len=" . + strlen($buffer), + ); - if ( is_wp_error( $health_response ) ) { - continue; - } + if (false === $result && !empty($curl_error)) { + return new WP_Error("curl_error", "cURL error: " . $curl_error); + } - $health_body = trim( (string) wp_remote_retrieve_body( $health_response ) ); - $health_code = (int) wp_remote_retrieve_response_code( $health_response ); - $health_json = json_decode( $health_body, true ); + if ($http_code >= 400) { + error_log( + "WPAW Local Backend API error: HTTP=" . + $http_code . + ", Buffer: " . + substr($buffer, 0, 1000), + ); + return new WP_Error( + "api_error", + sprintf( + "API error (%d): %s", + $http_code, + substr($buffer, 0, 500), + ), + ); + } - // Any 2xx indicates proxy process is reachable. - if ( $health_code >= 200 && $health_code < 300 ) { - $reachable = true; - } + // FALLBACK: If no SSE chunks were parsed, the proxy likely returned a plain JSON response. + // Try to parse the leftover buffer as a standard OpenAI/Anthropic JSON response. + if (empty($accumulated_content) && !empty($buffer)) { + error_log( + "WPAW Local Backend: No SSE chunks parsed. Attempting raw JSON fallback. Buffer preview: " . + substr($buffer, 0, 500), + ); + $raw_json = json_decode($buffer, true); + if (is_array($raw_json)) { + // Provider returned a structured error payload (e.g. HTTP 4xx/5xx + // from the upstream model). Surface the real message instead of a + // generic "empty response" so the user knows what actually failed. + if (isset($raw_json["error"])) { + $provider_error = is_array($raw_json["error"]) + ? $raw_json["error"]["message"] ?? + wp_json_encode($raw_json["error"]) + : (string) $raw_json["error"]; + error_log( + "WPAW Local Backend: Provider returned error payload: " . + substr((string) $provider_error, 0, 300), + ); + return new WP_Error( + "provider_error", + sprintf( + /* translators: %s: provider error message */ + __( + "The AI provider returned an error: %s", + "wp-agentic-writer", + ), + (string) $provider_error, + ), + ); + } + // OpenAI format: choices[0].message.content + if (isset($raw_json["choices"][0]["message"]["content"])) { + $accumulated_content = + $raw_json["choices"][0]["message"]["content"]; + error_log( + "WPAW Local Backend: Extracted content via OpenAI format fallback (" . + strlen($accumulated_content) . + " chars)", + ); + } + // Anthropic format: content[0].text + elseif (isset($raw_json["content"][0]["text"])) { + $accumulated_content = $raw_json["content"][0]["text"]; + error_log( + "WPAW Local Backend: Extracted content via Anthropic format fallback (" . + strlen($accumulated_content) . + " chars)", + ); + } + // Simple format: content string + elseif ( + isset($raw_json["content"]) && + is_string($raw_json["content"]) + ) { + $accumulated_content = $raw_json["content"]; + error_log( + "WPAW Local Backend: Extracted content via simple format fallback (" . + strlen($accumulated_content) . + " chars)", + ); + } - // Stronger signal for known proxy responses. - if ( strcasecmp( $health_body, 'pong' ) === 0 ) { - $reachable = true; - break; - } - if ( is_array( $health_json ) ) { - $ok_flag = $health_json['ok'] ?? $health_json['success'] ?? null; - $status = strtolower( (string) ( $health_json['status'] ?? '' ) ); - if ( true === $ok_flag || in_array( $status, array( 'ok', 'healthy', 'pong' ), true ) ) { - $reachable = true; - break; - } - } - } + if (!empty($accumulated_content) && $callback) { + // Emit the full content as a single chunk so the SSE handler picks it up + call_user_func( + $callback, + $accumulated_content, + false, + $accumulated_content, + ); + call_user_func($callback, "", true, $accumulated_content); + } - // Test actual inference with simple prompt - $test_response = wp_remote_post( - $this->base_url . '/v1/messages', - array( - 'headers' => array( - 'Content-Type' => 'application/json', - ), - 'body' => wp_json_encode( - array( - 'messages' => array( - array( - 'role' => 'user', - 'content' => 'Reply with exactly: Connection test successful', - ), - ), - ) - ), - 'timeout' => 30, - 'sslverify' => false, - ) - ); + // Extract usage if available + if (isset($raw_json["usage"])) { + $accumulated_usage = $raw_json["usage"]; + } + } else { + error_log( + "WPAW Local Backend: Buffer is not valid JSON. First 300 chars: " . + substr($buffer, 0, 300), + ); + } + } - if ( is_wp_error( $test_response ) ) { - // If both health and inference are unreachable, report connection issue. - if ( ! $reachable ) { - return new WP_Error( - 'ping_failed', - sprintf( - /* translators: %s: error message */ - __( 'Cannot reach proxy: %s. Is it running and reachable from this server?', 'wp-agentic-writer' ), - $test_response->get_error_message() - ) - ); - } - return new WP_Error( - 'inference_failed', - sprintf( - /* translators: %s: error message */ - __( 'Inference test failed: %s', 'wp-agentic-writer' ), - $test_response->get_error_message() - ) - ); - } + // If the stream completed with a tool/function finish reason but no + // text, an agentic/coding model was used for prose. Retrying won't help + // (it's a model-capability mismatch), so surface a clear error instead + // of burning two more calls on the non-streaming fallback. + if ( + empty($accumulated_content) && + ("malformed_function_call" === $finish_reason || + "tool_calls" === $finish_reason || + "function_call" === $finish_reason) + ) { + error_log( + "WPAW Local Backend: Empty content with finish_reason=" . + $finish_reason . + "; model=" . + $this->get_model_for_task($type), + ); + return new WP_Error( + "empty_agentic_response", + sprintf( + /* translators: %s: model identifier */ + __( + 'The selected model "%s" returned no text (finish reason: tool/function call). This usually means an agentic/coding model is being used for prose. Choose a standard chat model (without an -agent or -agentic suffix) in Settings.', + "wp-agentic-writer", + ), + $this->get_model_for_task($type), + ), + ); + } - $test_body = json_decode( wp_remote_retrieve_body( $test_response ), true ); - if ( ! isset( $test_body['choices'][0]['message']['content'] ) ) { - return new WP_Error( - 'invalid_response', - __( 'Claude CLI not responding correctly. Check proxy logs.', 'wp-agentic-writer' ) - ); - } + if (empty($accumulated_content)) { + error_log( + "WPAW Local Backend: Streaming returned empty content; falling back to non-streaming chat.", + ); + $fallback_response = $this->chat($messages, $options, $type); + if (is_wp_error($fallback_response)) { + return $fallback_response; + } + $accumulated_content = $fallback_response["content"] ?? ""; - return array( - 'success' => true, - 'message' => __( 'Connected! Proxy responding correctly.', 'wp-agentic-writer' ), - 'sample_response' => $test_body['choices'][0]['message']['content'], - ); - } + if ("" === trim((string) $accumulated_content)) { + error_log( + "WPAW Local Backend: Non-streaming fallback returned empty content; retrying once.", + ); + $fallback_response = $this->chat($messages, $options, $type); + if (is_wp_error($fallback_response)) { + return $fallback_response; + } + $accumulated_content = $fallback_response["content"] ?? ""; + } - /** - * Check if provider supports task type - * - * @param string $type Task type. - * @return bool True if supported (all text tasks). - */ - public function supports_task_type( $type ) { - // Local backend supports all text tasks, but not images - return in_array( - $type, - array( 'chat', 'clarity', 'planning', 'writing', 'refinement' ), - true - ); - } + if ("" === trim((string) $accumulated_content)) { + return new WP_Error( + "empty_response", + __( + "The provider returned an empty chat response.", + "wp-agentic-writer", + ), + ); + } + + if (!empty($accumulated_content) && $callback) { + call_user_func( + $callback, + $accumulated_content, + false, + $accumulated_content, + ); + call_user_func($callback, "", true, $accumulated_content); + } + + return [ + "content" => $accumulated_content, + "model" => + $fallback_response["model"] ?? + $this->get_model_for_task($type), + "input_tokens" => + $fallback_response["input_tokens"] ?? + ($accumulated_usage["prompt_tokens"] ?? 0), + "output_tokens" => + $fallback_response["output_tokens"] ?? + ($accumulated_usage["completion_tokens"] ?? 0), + "total_tokens" => + $fallback_response["total_tokens"] ?? + ($accumulated_usage["total_tokens"] ?? 0), + "cost" => $fallback_response["cost"] ?? 0, + "generation_time" => microtime(true) - $start_time, + ]; + } + + return [ + "content" => $accumulated_content, + "model" => $this->get_model_for_task($type), + "input_tokens" => $accumulated_usage["prompt_tokens"] ?? 0, + "output_tokens" => $accumulated_usage["completion_tokens"] ?? 0, + "total_tokens" => $accumulated_usage["total_tokens"] ?? 0, + "cost" => 0, + "generation_time" => microtime(true) - $start_time, + ]; + } + + /** + * Generate image via OpenAI-compatible endpoint + * + * @param string $prompt Image prompt. + * @param string $model Model to use (falls back to configured model). + * @param array $options Optional parameters (size, quality, n). + * @return array|WP_Error Response with url, model, cost, or error. + */ + public function generate_image($prompt, $model = null, $options = []) + { + if (empty($this->image_base_url)) { + return new WP_Error( + "no_api_url", + __( + "Custom Endpoint URL for Images is not configured.", + "wp-agentic-writer", + ), + ); + } + + if (empty($model)) { + $model = $this->get_model_for_task("image"); + } + + if (empty($model)) { + return new WP_Error( + "no_model", + __( + "Custom Endpoint Image Generation Model is not defined. Please configure a valid model code in Settings > Custom Endpoint.", + "wp-agentic-writer", + ), + ); + } + + $base = rtrim($this->image_base_url, "/"); + if (!preg_match('#/v1$#', $base)) { + $base .= "/v1"; + } + $endpoint = $base . "/images/generations"; + + $body = [ + "model" => $model, + "prompt" => $prompt, + "n" => $options["n"] ?? 1, + "size" => $options["size"] ?? "1024x1024", + ]; + + // Pass along standard DALL-E style optional params if present + if (isset($options["quality"])) { + $body["quality"] = $options["quality"]; + } + if (isset($options["style"])) { + $body["style"] = $options["style"]; + } + + $args = [ + "body" => wp_json_encode($body), + "headers" => [ + "Content-Type" => "application/json", + ], + "timeout" => 60, + ]; + + if (!empty($this->image_api_key)) { + $args["headers"]["Authorization"] = + "Bearer " . $this->image_api_key; + } + + $start_time = microtime(true); + $response = wp_remote_post($endpoint, $args); + $generation_time = microtime(true) - $start_time; + + if (is_wp_error($response)) { + return $response; + } + + $status_code = wp_remote_retrieve_response_code($response); + $body_response = json_decode(wp_remote_retrieve_body($response), true); + + if (200 !== $status_code) { + $error_msg = + $body_response["error"]["message"] ?? + "Unknown error generating image via custom endpoint."; + return new WP_Error("api_error", $error_msg, [ + "status" => $status_code, + ]); + } + + if (empty($body_response["data"][0]["url"])) { + return new WP_Error( + "api_error", + "No image URL returned by custom endpoint.", + ); + } + + return [ + "url" => $body_response["data"][0]["url"], + "cost" => 0, + "generation_time" => $generation_time, + "model" => $model, + "prompt" => $prompt, + "input_tokens" => 0, + "output_tokens" => 0, + ]; + } + + /** + * Check if provider is configured + * + * @return bool True if base URL is set. + */ + public function is_configured() + { + return !empty($this->base_url); + } + + /** + * Test connection to local backend + * + * @return array|WP_Error Success array or error. + */ + public function test_connection() + { + if (!$this->is_configured()) { + return new WP_Error( + "not_configured", + __("Custom endpoint URL not configured", "wp-agentic-writer"), + ); + } + + // Best-effort reachability checks. Do not hard-fail here; inference test below is authoritative. + $reachable = false; + $health_endpoints = ["/ping", "/health", "/"]; + foreach ($health_endpoints as $endpoint) { + $health_response = wp_remote_get($this->base_url . $endpoint, [ + "timeout" => 5, + "sslverify" => false, + ]); + + if (is_wp_error($health_response)) { + continue; + } + + $health_body = trim( + (string) wp_remote_retrieve_body($health_response), + ); + $health_code = (int) wp_remote_retrieve_response_code( + $health_response, + ); + $health_json = json_decode($health_body, true); + + // Any 2xx indicates proxy process is reachable. + if ($health_code >= 200 && $health_code < 300) { + $reachable = true; + } + + // Stronger signal for known proxy responses. + if (strcasecmp($health_body, "pong") === 0) { + $reachable = true; + break; + } + if (is_array($health_json)) { + $ok_flag = + $health_json["ok"] ?? ($health_json["success"] ?? null); + $status = strtolower((string) ($health_json["status"] ?? "")); + if ( + true === $ok_flag || + in_array($status, ["ok", "healthy", "pong"], true) + ) { + $reachable = true; + break; + } + } + } + + // Test actual inference with simple prompt + $test_response = wp_remote_post( + $this->get_endpoint_url("/chat/completions"), + [ + "headers" => [ + "Content-Type" => "application/json", + "Authorization" => "Bearer " . $this->api_key, + ], + "body" => wp_json_encode([ + "model" => $this->model, + "messages" => [ + [ + "role" => "user", + "content" => + "Reply with exactly: Connection test successful", + ], + ], + "stream" => false, + ]), + "timeout" => 30, + "sslverify" => false, + ], + ); + + if (is_wp_error($test_response)) { + // If both health and inference are unreachable, report connection issue. + if (!$reachable) { + return new WP_Error( + "ping_failed", + sprintf( + /* translators: %s: error message */ + __( + "Cannot reach endpoint: %s. Is it running and reachable from this server?", + "wp-agentic-writer", + ), + $test_response->get_error_message(), + ), + ); + } + return new WP_Error( + "inference_failed", + sprintf( + /* translators: %s: error message */ + __("Inference test failed: %s", "wp-agentic-writer"), + $test_response->get_error_message(), + ), + ); + } + + $code = wp_remote_retrieve_response_code($test_response); + $raw_body = wp_remote_retrieve_body($test_response); + $test_body = json_decode($raw_body, true); + + if ($code >= 400) { + $error_msg = + $test_body["error"]["message"] ?? substr($raw_body, 0, 150); + return new WP_Error( + "api_error", + sprintf( + /* translators: %1$d: HTTP status code, %2$s: error message */ + __('API Error (HTTP %1$d): %2$s', "wp-agentic-writer"), + $code, + esc_html($error_msg), + ), + ); + } + + if (!isset($test_body["choices"][0]["message"]["content"])) { + // Some endpoints might still ignore stream=false and return SSE chunks. + // If the response starts with "data: ", try to parse the first chunk's content. + if (strpos($raw_body, "data: ") === 0) { + $lines = explode("\n", $raw_body); + $content = ""; + foreach ($lines as $line) { + if (strpos($line, "data: ") === 0) { + $json_str = substr($line, 6); + if ($json_str === "[DONE]") { + continue; + } + $chunk = json_decode($json_str, true); + if (isset($chunk["choices"][0]["delta"]["content"])) { + $content .= + $chunk["choices"][0]["delta"]["content"]; + } + } + } + + if (!empty($content)) { + return [ + "success" => true, + "message" => __( + "Connected! Endpoint responding correctly.", + "wp-agentic-writer", + ), + "sample_response" => $content, + ]; + } + } + + return new WP_Error( + "invalid_response", + __( + "Endpoint not responding with expected OpenAI-compatible format. Check your URL and API key.", + "wp-agentic-writer", + ) . + " Response preview: " . + substr($raw_body, 0, 100), + ); + } + + return [ + "success" => true, + "message" => __( + "Connected! Endpoint responding correctly.", + "wp-agentic-writer", + ), + "sample_response" => $test_body["choices"][0]["message"]["content"], + ]; + } + + /** + * Check if provider supports task type + * + * @param string $type Task type. + * @return bool True if supported. + */ + public function supports_task_type($type) + { + // Custom endpoint supports both text and image tasks + return in_array( + $type, + ["chat", "clarity", "planning", "writing", "refinement", "image"], + true, + ); + } } diff --git a/includes/class-settings-v2.php b/includes/class-settings-v2.php index 2fab01c..c22dad3 100644 --- a/includes/class-settings-v2.php +++ b/includes/class-settings-v2.php @@ -2,7 +2,7 @@ /** * Settings Page V2 * - * Refactored settings page with Bootstrap design and separated view files. + * Refactored settings page with Agentic design tokens and separated view files. * * @package WP_Agentic_Writer */ @@ -75,6 +75,93 @@ class WP_Agentic_Writer_Settings_V2 "ajax_test_local_backend", ]); add_action("wp_ajax_wpaw_test_memanto", [$this, "ajax_test_memanto"]); + + // Clear connection test cache when local backend settings change + add_action( + "updated_option", + [$this, "clear_local_backend_cache_on_settings_change"], + 10, + 3, + ); + } + + /** + * Clear the local backend connection test cache when relevant settings change. + * + * This ensures that after a user updates their local backend URL, API key, + * or model settings, the cached "connection test" result is invalidated + * so the next chat request will re-test the connection. + * + * @since 0.2.0 + * @param string $option Option name that was updated. + * @param mixed $old_value Old option value. + * @param mixed $new_value New option value. + */ + public function clear_local_backend_cache_on_settings_change( + $option, + $old_value, + $new_value, + ) { + // Only handle our settings option + if ("wp_agentic_writer_settings" !== $option) { + return; + } + + // Check if any local backend-related setting changed + $local_backend_keys = [ + "local_backend_url", + "local_backend_key", + "local_backend_image_url", + "local_backend_image_key", + "local_backend_model", + "local_backend_models", + "local_backend_enabled", + "local_backend_image_enabled", + ]; + + $old_value = is_array($old_value) ? $old_value : []; + $new_value = is_array($new_value) ? $new_value : []; + + foreach ($local_backend_keys as $key) { + $old = $old_value[$key] ?? null; + $new = $new_value[$key] ?? null; + + // Check if the value changed (handle both array and scalar comparisons) + if ($old !== $new) { + // Arrays need special comparison + if (is_array($old) && is_array($new)) { + if (serialize($old) !== serialize($new)) { + $this->do_clear_local_backend_cache(); + return; + } + } else { + $this->do_clear_local_backend_cache(); + return; + } + } + } + } + + /** + * Actually clear the cache and log the action. + * + * @since 0.2.0 + */ + private function do_clear_local_backend_cache() + { + if (!class_exists("WP_Agentic_Writer_Provider_Manager")) { + return; + } + + $count = WP_Agentic_Writer_Provider_Manager::clear_connection_test_cache(); + + if (defined("WP_DEBUG") && WP_DEBUG) { + error_log( + "WPAW Settings: Local backend settings changed. Cleared " . + $count . + " connection test cache entries.", + ); + } } /** @@ -89,115 +176,36 @@ class WP_Agentic_Writer_Settings_V2 return; } - // Bootstrap 5.3 + $settings_css_path = + WP_AGENTIC_WRITER_DIR . "views/settings-v2/style.css"; wp_enqueue_style( - "bootstrap", - "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css", + "wpaw-settings-v2-stitch", + WP_AGENTIC_WRITER_URL . "views/settings-v2/style.css", [], - "5.3.3", - ); - wp_enqueue_style( - "bootstrap-icons", - "https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css", - [], - "1.11.1", - ); - wp_enqueue_script( - "bootstrap", - "https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js", - [], - "5.3.3", - true, + file_exists($settings_css_path) + ? filemtime($settings_css_path) + : WP_AGENTIC_WRITER_VERSION, ); - // Select2 for searchable dropdowns wp_enqueue_style( - "select2", - "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css", + "wpaw-select2", + WP_AGENTIC_WRITER_URL . "assets/vendor/select2/select2.min.css", [], - "4.1.0", - ); - wp_enqueue_style( - "select2-bootstrap-5", - "https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css", - ["select2", "bootstrap"], - "1.3.0", + "4.1.0-rc.0", ); + wp_enqueue_script( - "select2", - "https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js", + "wpaw-select2", + WP_AGENTIC_WRITER_URL . "assets/vendor/select2/select2.min.js", ["jquery"], - "4.1.0", + "4.1.0-rc.0", true, ); - // Agentic Vibe CSS - Design System (in order) - wp_enqueue_style( - "wpaw-agentic-variables", - WP_AGENTIC_WRITER_URL . "assets/css/agentic-variables.css", - [], - WP_AGENTIC_WRITER_VERSION, - ); - wp_enqueue_style( - "wpaw-agentic-bootstrap-custom", - WP_AGENTIC_WRITER_URL . "assets/css/agentic-bootstrap-custom.css", - ["bootstrap", "wpaw-agentic-variables"], - WP_AGENTIC_WRITER_VERSION, - ); - wp_enqueue_style( - "wpaw-agentic-components", - WP_AGENTIC_WRITER_URL . "assets/css/agentic-components.css", - ["wpaw-agentic-variables"], - WP_AGENTIC_WRITER_VERSION, - ); - wp_enqueue_style( - "wpaw-agentic-workflow", - WP_AGENTIC_WRITER_URL . "assets/css/agentic-workflow.css", - ["wpaw-agentic-components"], - WP_AGENTIC_WRITER_VERSION, - ); - - // Legacy plugin styles - $css_admin_path = WP_AGENTIC_WRITER_DIR . "assets/css/admin-v2.css"; - $css_settings_path = - WP_AGENTIC_WRITER_DIR . "assets/css/settings-v2.css"; - $css_log_path = - WP_AGENTIC_WRITER_DIR . "assets/css/cost-log-grouped.css"; - - $ver_admin = file_exists($css_admin_path) - ? filemtime($css_admin_path) - : WP_AGENTIC_WRITER_VERSION; - $ver_settings = file_exists($css_settings_path) - ? filemtime($css_settings_path) - : WP_AGENTIC_WRITER_VERSION; - $ver_log = file_exists($css_log_path) - ? filemtime($css_log_path) - : WP_AGENTIC_WRITER_VERSION; - - wp_enqueue_style( - "wp-agentic-writer-admin-v2", - WP_AGENTIC_WRITER_URL . "assets/css/admin-v2.css", - ["bootstrap", "select2-bootstrap-5"], - $ver_admin, - ); - wp_enqueue_style( - "wp-agentic-writer-settings-v2", - WP_AGENTIC_WRITER_URL . "assets/css/settings-v2.css", - ["wpaw-agentic-components"], - $ver_settings, - ); - wp_enqueue_style( - "wp-agentic-writer-cost-log-grouped", - WP_AGENTIC_WRITER_URL . "assets/css/cost-log-grouped.css", - ["wp-agentic-writer-settings-v2"], - $ver_log, - ); - - // Plugin scripts wp_enqueue_script( "wp-agentic-writer-settings-v2", - WP_AGENTIC_WRITER_URL . "assets/js/settings-v2.js", - ["jquery", "bootstrap", "select2"], + WP_AGENTIC_WRITER_URL . "assets/js/settings-v2-stitch.js", + ["jquery", "wpaw-select2"], WP_AGENTIC_WRITER_VERSION, true, ); @@ -282,8 +290,8 @@ class WP_Agentic_Writer_Settings_V2 "chat" => "google/gemini-2.5-flash", "clarity" => "google/gemini-2.5-flash", "planning" => "google/gemini-2.5-flash", - "writing" => "anthropic/claude-3.5-sonnet", - "refinement" => "anthropic/claude-3.5-sonnet", + "writing" => "anthropic/claude-sonnet-4", + "refinement" => "anthropic/claude-sonnet-4", "image" => "openai/gpt-4o", ], "premium" => [ @@ -704,7 +712,17 @@ class WP_Agentic_Writer_Settings_V2 wp_send_json_error(["message" => "Permission denied"]); } - $provider = WP_Agentic_Writer_OpenRouter_Provider::get_instance(); + $settings = get_option("wp_agentic_writer_settings", []); + $posted_api_key = isset($_POST["api_key"]) + ? trim(sanitize_text_field(wp_unslash($_POST["api_key"]))) + : ""; + $api_key = !empty($posted_api_key) + ? $posted_api_key + : $settings["openrouter_api_key"] ?? ""; + + $provider = !empty($posted_api_key) + ? WP_Agentic_Writer_OpenRouter_Provider::for_api_key($api_key) + : WP_Agentic_Writer_OpenRouter_Provider::get_instance(); $models = $provider->fetch_and_cache_models(true); if (is_wp_error($models)) { @@ -1025,6 +1043,58 @@ class WP_Agentic_Writer_Settings_V2 ...$post_ids, ); $detail_rows = $wpdb->get_results($details_sql, ARRAY_A); + + $image_variants_table = $wpdb->prefix . "wpaw_images_variants"; + $image_variants_table_exists = + $wpdb->get_var("SHOW TABLES LIKE '{$image_variants_table}'") === + $image_variants_table; + if ($image_variants_table_exists) { + // Find posts that already have image_generation in wpaw_cost_tracking + // to avoid duplicates from the variants table. + $posts_with_tracked_images = []; + foreach ($detail_rows as $existing) { + if (($existing["action"] ?? "") === "image_generation") { + $posts_with_tracked_images[ + (int) ($existing["post_id"] ?? 0) + ] = true; + } + } + $variant_post_ids = array_filter($post_ids, function ( + $pid, + ) use ($posts_with_tracked_images) { + return empty($posts_with_tracked_images[(int) $pid]); + }); + if (!empty($variant_post_ids)) { + $variant_placeholders = implode( + ",", + array_fill(0, count($variant_post_ids), "%d"), + ); + $image_details_sql = $wpdb->prepare( + "SELECT post_id, created_at, image_model_used AS model, cost + FROM {$image_variants_table} + WHERE post_id IN ({$variant_placeholders}) AND cost IS NOT NULL AND cost > 0 + ORDER BY created_at DESC", + ...$variant_post_ids, + ); + $image_detail_rows = $wpdb->get_results( + $image_details_sql, + ARRAY_A, + ); + foreach ($image_detail_rows as $image_detail_row) { + $detail_rows[] = [ + "post_id" => + (int) ($image_detail_row["post_id"] ?? 0), + "created_at" => + $image_detail_row["created_at"] ?? "", + "model" => $image_detail_row["model"] ?? "", + "action" => "image_generation", + "input_tokens" => 0, + "output_tokens" => 0, + "cost" => $image_detail_row["cost"] ?? 0, + ]; + } + } + } $detail_map = []; foreach ($detail_rows as $detail_row) { $pid = (int) ($detail_row["post_id"] ?? 0); @@ -1221,7 +1291,7 @@ class WP_Agentic_Writer_Settings_V2 // Check for specific models $check_models = [ "deepseek/deepseek-chat-v3-0324", - "anthropic/claude-3.5-sonnet", + "anthropic/claude-sonnet-4", ]; $found_models = []; $missing_models = []; @@ -1278,7 +1348,12 @@ class WP_Agentic_Writer_Settings_V2 } $settings = get_option("wp_agentic_writer_settings", []); - $api_key = $settings["openrouter_api_key"] ?? ""; + $posted_api_key = isset($_POST["api_key"]) + ? trim(sanitize_text_field(wp_unslash($_POST["api_key"]))) + : ""; + $api_key = !empty($posted_api_key) + ? $posted_api_key + : $settings["openrouter_api_key"] ?? ""; if (empty($api_key)) { wp_send_json_error(["message" => "API key is not configured"]); @@ -1394,6 +1469,12 @@ class WP_Agentic_Writer_Settings_V2 ); } + if (isset($input["custom_search_url"])) { + $sanitized["custom_search_url"] = esc_url_raw( + trim($input["custom_search_url"]), + ); + } + // Sanitize model names (6 models) - using model registry for defaults $sanitized["chat_model"] = sanitize_text_field( $input["chat_model"] ?? @@ -1547,14 +1628,29 @@ class WP_Agentic_Writer_Settings_V2 isset($input["custom_languages"]) && is_array($input["custom_languages"]) ) { + $custom_language_values = []; + foreach ($input["custom_languages"] as $custom_language_value) { + $custom_language_values = array_merge( + $custom_language_values, + array_map( + "trim", + explode(",", (string) $custom_language_value), + ), + ); + } $sanitized["custom_languages"] = array_filter( - array_map("sanitize_text_field", $input["custom_languages"]), + array_map("sanitize_text_field", $custom_language_values), ); } else { $sanitized["custom_languages"] = []; } - // Sanitize Local Backend settings (Fix for settings wiping out) + // Sanitize Global Context + $sanitized["global_context"] = isset($input["global_context"]) + ? sanitize_textarea_field(trim($input["global_context"])) + : ""; + + // Sanitize Custom Endpoint settings (Fix for settings wiping out) if (isset($input["local_backend_url"])) { $sanitized["local_backend_url"] = esc_url_raw( trim($input["local_backend_url"]), @@ -1567,12 +1663,57 @@ class WP_Agentic_Writer_Settings_V2 ); } + if (isset($input["local_backend_image_url"])) { + $sanitized["local_backend_image_url"] = esc_url_raw( + trim($input["local_backend_image_url"]), + ); + } + + if (isset($input["local_backend_image_key"])) { + $sanitized["local_backend_image_key"] = sanitize_text_field( + trim($input["local_backend_image_key"]), + ); + } + if (isset($input["local_backend_model"])) { $sanitized["local_backend_model"] = sanitize_text_field( trim($input["local_backend_model"]), ); } + $sanitized["local_backend_enabled"] = + isset($input["local_backend_enabled"]) && + "1" === $input["local_backend_enabled"]; + + $sanitized["local_backend_image_enabled"] = + isset($input["local_backend_image_enabled"]) && + "1" === $input["local_backend_image_enabled"]; + + // Per-task model overrides for custom endpoint + if ( + isset($input["local_backend_models"]) && + is_array($input["local_backend_models"]) + ) { + $sanitized_models = []; + $allowed_model_tasks = [ + "chat", + "clarity", + "planning", + "writing", + "refinement", + "image", + ]; + foreach ($input["local_backend_models"] as $task => $model_code) { + $task = sanitize_text_field($task); + if (in_array($task, $allowed_model_tasks, true)) { + $sanitized_models[$task] = sanitize_text_field( + trim($model_code), + ); + } + } + $sanitized["local_backend_models"] = $sanitized_models; + } + // Sanitize MEMANTO settings. $sanitized["memanto_enabled"] = isset($input["memanto_enabled"]) && @@ -1580,14 +1721,44 @@ class WP_Agentic_Writer_Settings_V2 $sanitized["memanto_url"] = isset($input["memanto_url"]) ? esc_url_raw(trim($input["memanto_url"])) : ""; + $sanitized["memanto_license_key"] = isset($input["memanto_license_key"]) + ? sanitize_text_field(trim($input["memanto_license_key"])) + : ""; $sanitized["memanto_moorcheh_key"] = isset( $input["memanto_moorcheh_key"], ) ? sanitize_text_field(trim($input["memanto_moorcheh_key"])) : ""; - // Sanitize Task Providers Routing - if ( + // Sanitize Task Providers Routing. + // When custom endpoint is enabled, auto-build task_providers from + // the per-task model codes: a non-empty model code ⇒ local_backend. + if (!empty($sanitized["local_backend_enabled"])) { + $sanitized_providers = []; + $lb_models = $sanitized["local_backend_models"] ?? []; + $all_tasks = [ + "chat", + "clarity", + "planning", + "writing", + "refinement", + "image", + ]; + foreach ($all_tasks as $task) { + if ($task === "image") { + $sanitized_providers[$task] = !empty( + $sanitized["local_backend_image_enabled"] + ) + ? "local_backend" + : "openrouter"; + } else { + $sanitized_providers[$task] = !empty($lb_models[$task]) + ? "local_backend" + : "openrouter"; + } + } + $sanitized["task_providers"] = $sanitized_providers; + } elseif ( isset($input["task_providers"]) && is_array($input["task_providers"]) ) { @@ -1607,12 +1778,7 @@ class WP_Agentic_Writer_Settings_V2 $provider = sanitize_text_field($provider); if (in_array($task, $allowed_tasks, true)) { - if ("image" === $task && "openrouter" === $provider) { - $sanitized_providers[$task] = $provider; - } elseif ( - "image" !== $task && - in_array($provider, $allowed_providers_text, true) - ) { + if (in_array($provider, $allowed_providers_text, true)) { $sanitized_providers[$task] = $provider; } } @@ -1635,8 +1801,8 @@ class WP_Agentic_Writer_Settings_V2 // Extract settings for views $view_data = $this->prepare_view_data($settings); - // Include main layout - include WP_AGENTIC_WRITER_DIR . "views/settings/layout.php"; + // Include Stitch rebuild layout + include WP_AGENTIC_WRITER_DIR . "views/settings-v2/layout.php"; } /** @@ -1651,6 +1817,7 @@ class WP_Agentic_Writer_Settings_V2 // Extract settings (6 models) using model registry for defaults $api_key = $settings["openrouter_api_key"] ?? ""; $brave_search_api_key = $settings["brave_search_api_key"] ?? ""; + $custom_search_url = $settings["custom_search_url"] ?? ""; $chat_model = $settings["chat_model"] ?? WPAW_Model_Registry::get_default_model("chat"); @@ -1698,17 +1865,26 @@ class WP_Agentic_Writer_Settings_V2 "Indonesian", ]; $custom_languages = $settings["custom_languages"] ?? []; + $global_context = $settings["global_context"] ?? ""; + $available_languages = $this->get_available_languages(); $custom_models = get_option("wp_agentic_writer_custom_models", []); - // Local Backend settings + // Custom Endpoint settings $local_backend_url = $settings["local_backend_url"] ?? ""; - $local_backend_key = $settings["local_backend_key"] ?? "dummy"; - $local_backend_model = - $settings["local_backend_model"] ?? "claude-local"; + $local_backend_key = $settings["local_backend_key"] ?? ""; + $local_backend_image_url = $settings["local_backend_image_url"] ?? ""; + $local_backend_image_key = $settings["local_backend_image_key"] ?? ""; + $local_backend_model = $settings["local_backend_model"] ?? ""; + $local_backend_enabled = !empty($settings["local_backend_enabled"]); + $local_backend_image_enabled = !empty( + $settings["local_backend_image_enabled"] + ); + $local_backend_models = $settings["local_backend_models"] ?? []; // MEMANTO settings $memanto_enabled = $settings["memanto_enabled"] ?? false; $memanto_url = $settings["memanto_url"] ?? ""; + $memanto_license_key = $settings["memanto_license_key"] ?? ""; $memanto_moorcheh_key = $settings["memanto_moorcheh_key"] ?? ""; $task_providers = $settings["task_providers"] ?? []; $allow_openrouter_fallback = !empty( @@ -1741,6 +1917,7 @@ class WP_Agentic_Writer_Settings_V2 return compact( "api_key", "brave_search_api_key", + "custom_search_url", "chat_model", "clarity_model", "planning_model", @@ -1759,13 +1936,20 @@ class WP_Agentic_Writer_Settings_V2 "required_context_categories", "preferred_languages", "custom_languages", + "global_context", + "available_languages", "custom_models", "monthly_used", "budget_percent", "budget_status", "local_backend_url", "local_backend_key", + "local_backend_image_url", + "local_backend_image_key", "local_backend_model", + "local_backend_enabled", + "local_backend_image_enabled", + "local_backend_models", "task_providers", "allow_openrouter_fallback", "openrouter_provider_routing_enabled", @@ -1774,6 +1958,7 @@ class WP_Agentic_Writer_Settings_V2 "openrouter_allow_provider_fallbacks", "memanto_enabled", "memanto_url", + "memanto_license_key", "memanto_moorcheh_key", "settings", ); @@ -1822,26 +2007,37 @@ class WP_Agentic_Writer_Settings_V2 */ public function ajax_test_local_backend() { - check_ajax_referer("wpaw_test_local_backend", "nonce"); + check_ajax_referer("wpaw_settings", "nonce"); if (!current_user_can("manage_options")) { wp_send_json_error(["message" => "Insufficient permissions"]); } $url = sanitize_text_field(wp_unslash($_POST["url"] ?? "")); + $key = sanitize_text_field(wp_unslash($_POST["key"] ?? "")); + $model = sanitize_text_field(wp_unslash($_POST["model"] ?? "")); if (empty($url)) { wp_send_json_error(["message" => "URL required"]); } - // Temporarily create provider with this URL - $temp_settings = get_option("wp_agentic_writer_settings", []); + // Temporarily create provider with these form values. + $original_settings = get_option("wp_agentic_writer_settings", []); + $temp_settings = $original_settings; $temp_settings["local_backend_url"] = $url; + if ("" !== $key) { + $temp_settings["local_backend_key"] = $key; + } + if ("" !== $model) { + $temp_settings["local_backend_model"] = $model; + } update_option("wp_agentic_writer_settings", $temp_settings); $provider = new WP_Agentic_Writer_Local_Backend_Provider(); $result = $provider->test_connection(); + update_option("wp_agentic_writer_settings", $original_settings); + if (is_wp_error($result)) { wp_send_json_error(["message" => $result->get_error_message()]); } @@ -1875,7 +2071,8 @@ class WP_Agentic_Writer_Settings_V2 } // Temporarily override settings so the client uses the form values. - $temp_settings = get_option("wp_agentic_writer_settings", []); + $original_settings = get_option("wp_agentic_writer_settings", []); + $temp_settings = $original_settings; $temp_settings["memanto_url"] = esc_url_raw(trim($url)); $temp_settings["memanto_moorcheh_key"] = sanitize_text_field( trim($key), @@ -1885,8 +2082,12 @@ class WP_Agentic_Writer_Settings_V2 // Clear health cache so the fresh URL/key are used. delete_transient("wpaw_memanto_health"); - $client = WP_Agentic_Writer_Memanto_Client::get_instance(); + $client = WP_Agentic_Writer_Memanto_Client::for_base_url( + esc_url_raw(trim($url)), + ); $result = $client->check_health_fresh(); + update_option("wp_agentic_writer_settings", $original_settings); + delete_transient("wpaw_memanto_health"); if ($result["healthy"]) { wp_send_json_success($result);