- Implement local backend AI provider with Ollama integration - Add Brave Search API integration for real-time search suggestions - Add image generation manager with multiple AI providers - Create hybrid provider system with local/cloud fallback - Add comprehensive settings UI with provider management - Implement Gutenberg sidebar with writing assistance controls - Add SEO schema generation for AI-generated content - Multiple provider support: OpenRouter, local backend, Codex
417 lines
12 KiB
PHP
417 lines
12 KiB
PHP
<?php
|
|
/**
|
|
* Local Backend Provider
|
|
*
|
|
* Connects to user's local Claude CLI proxy for AI inference
|
|
*
|
|
* @package WP_Agentic_Writer
|
|
*/
|
|
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
class WP_Agentic_Writer_Local_Backend_Provider implements WP_Agentic_Writer_AI_Provider_Interface {
|
|
|
|
/**
|
|
* Local backend base URL
|
|
*
|
|
* @var string
|
|
*/
|
|
private $base_url = '';
|
|
|
|
/**
|
|
* API key (dummy for local backend)
|
|
*
|
|
* @var string
|
|
*/
|
|
private $api_key = 'dummy';
|
|
|
|
/**
|
|
* Model identifier
|
|
*
|
|
* @var string
|
|
*/
|
|
private $model = 'claude-local';
|
|
|
|
/**
|
|
* 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';
|
|
}
|
|
|
|
/**
|
|
* 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' )
|
|
);
|
|
}
|
|
|
|
$start_time = microtime( true );
|
|
|
|
$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
|
|
)
|
|
);
|
|
|
|
$generation_time = microtime( true ) - $start_time;
|
|
|
|
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()
|
|
)
|
|
);
|
|
}
|
|
|
|
$code = wp_remote_retrieve_response_code( $response );
|
|
if ( 200 !== $code ) {
|
|
$body = wp_remote_retrieve_body( $response );
|
|
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
|
|
)
|
|
);
|
|
}
|
|
|
|
$body = json_decode( wp_remote_retrieve_body( $response ), true );
|
|
|
|
if ( ! isset( $body['choices'][0]['message']['content'] ) ) {
|
|
return new WP_Error(
|
|
'invalid_response',
|
|
__( 'Invalid response format from Local Backend', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
$content = $body['choices'][0]['message']['content'];
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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' )
|
|
);
|
|
}
|
|
|
|
$body = array(
|
|
'messages' => $messages,
|
|
'stream' => true,
|
|
);
|
|
|
|
$accumulated_content = '';
|
|
$accumulated_usage = array();
|
|
$buffer = '';
|
|
|
|
$accumulating_callback = function( $chunk, $is_complete ) use ( &$accumulated_content, $callback ) {
|
|
if ( ! $is_complete && ! empty( $chunk ) ) {
|
|
$accumulated_content .= $chunk;
|
|
}
|
|
|
|
if ( $callback ) {
|
|
call_user_func( $callback, $chunk, $is_complete, $accumulated_content );
|
|
}
|
|
};
|
|
|
|
$ch = curl_init( $this->base_url . '/v1/messages' );
|
|
|
|
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;
|
|
|
|
while ( true ) {
|
|
$newline_pos = strpos( $buffer, "\n" );
|
|
if ( false === $newline_pos ) {
|
|
break;
|
|
}
|
|
|
|
$line = substr( $buffer, 0, $newline_pos );
|
|
$buffer = substr( $buffer, $newline_pos + 1 );
|
|
|
|
$line = trim( $line );
|
|
if ( empty( $line ) || ! str_starts_with( $line, 'data: ' ) ) {
|
|
continue;
|
|
}
|
|
|
|
$json_str = substr( $line, 6 );
|
|
|
|
if ( '[DONE]' === $json_str || '"[DONE]"' === $json_str ) {
|
|
call_user_func( $accumulating_callback, '', true );
|
|
return strlen( $data );
|
|
}
|
|
|
|
$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 );
|
|
}
|
|
|
|
if ( isset( $chunk['usage'] ) ) {
|
|
$accumulated_usage = $chunk['usage'];
|
|
}
|
|
}
|
|
|
|
return strlen( $data );
|
|
},
|
|
CURLOPT_HTTPHEADER => array(
|
|
'Content-Type: application/json',
|
|
'Authorization: Bearer ' . $this->api_key,
|
|
),
|
|
CURLOPT_POSTFIELDS => wp_json_encode( $body ),
|
|
CURLOPT_TIMEOUT => 300,
|
|
) );
|
|
|
|
$start_time = microtime( true );
|
|
$result = curl_exec( $ch );
|
|
$http_code = curl_getinfo( $ch, CURLINFO_HTTP_CODE );
|
|
$curl_error = curl_error( $ch );
|
|
curl_close( $ch );
|
|
|
|
// 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 ( false === $result && ! empty( $curl_error ) ) {
|
|
return new WP_Error( 'curl_error', 'cURL error: ' . $curl_error );
|
|
}
|
|
|
|
if ( $http_code >= 400 ) {
|
|
return new WP_Error( 'api_error', sprintf( 'API error (%d): %s', $http_code, $buffer ) );
|
|
}
|
|
|
|
// 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)' );
|
|
}
|
|
|
|
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 );
|
|
}
|
|
|
|
// 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 ) );
|
|
}
|
|
}
|
|
|
|
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,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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' )
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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',
|
|
__( 'Local Backend URL not configured', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
// Test /ping endpoint
|
|
$ping_response = wp_remote_get(
|
|
$this->base_url . '/ping',
|
|
array(
|
|
'timeout' => 5,
|
|
'sslverify' => false,
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $ping_response ) ) {
|
|
return new WP_Error(
|
|
'ping_failed',
|
|
sprintf(
|
|
/* translators: %s: error message */
|
|
__( 'Cannot reach proxy: %s. Is it running?', 'wp-agentic-writer' ),
|
|
$ping_response->get_error_message()
|
|
)
|
|
);
|
|
}
|
|
|
|
$ping_body = wp_remote_retrieve_body( $ping_response );
|
|
if ( 'pong' !== $ping_body ) {
|
|
return new WP_Error(
|
|
'invalid_ping',
|
|
__( 'Proxy responded but with unexpected format', 'wp-agentic-writer' )
|
|
);
|
|
}
|
|
|
|
// 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,
|
|
)
|
|
);
|
|
|
|
if ( is_wp_error( $test_response ) ) {
|
|
return new WP_Error(
|
|
'inference_failed',
|
|
sprintf(
|
|
/* translators: %s: error message */
|
|
__( 'Inference test failed: %s', 'wp-agentic-writer' ),
|
|
$test_response->get_error_message()
|
|
)
|
|
);
|
|
}
|
|
|
|
$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' )
|
|
);
|
|
}
|
|
|
|
return array(
|
|
'success' => true,
|
|
'message' => __( 'Connected! Proxy 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 (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
|
|
);
|
|
}
|
|
}
|