Files
wp-agentic-writer/includes/class-local-backend-provider.php
Dwindi Ramadhana d2c10756ab Add AI writing assistant plugin with local backend, brave search, and image generation support
- 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
2026-05-17 10:48:05 +07:00

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