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);