| 1 |
<?php |
| 2 |
/** |
| 3 |
* MxChat Editor Assistant — streaming transform endpoint (v0.2). |
| 4 |
* |
| 5 |
* Server-Sent Events over admin-ajax (NOT REST). WordPress' REST layer buffers |
| 6 |
* the whole response before serializing, which defeats token-by-token streaming; |
| 7 |
* admin-ajax lets us set our own headers and flush() chunks as they arrive. This |
| 8 |
* mirrors the proven streaming pattern mxchat-basic already ships for its chat |
| 9 |
* widget (class-mxchat-admin.php perform_streaming_test / process_streaming_test_data). |
| 10 |
* |
| 11 |
* POST admin-ajax.php?action=mxchat_editor_assistant_stream |
| 12 |
* { action: <slug>, content: <string>, locale?: <iso>, nonce: <stream nonce> } |
| 13 |
* → SSE frames: `data: {"content":"<chunk>"}` … then `data: [DONE]` |
| 14 |
* (errors arrive as `data: {"error":"<message>"}` then [DONE]) |
| 15 |
* |
| 16 |
* Streaming providers: OpenAI (gpt-*), Anthropic (claude-*), Grok (grok-*), and |
| 17 |
* the custom OpenAI-compatible provider. Gemini and any non-streaming provider |
| 18 |
* degrade gracefully here — a single one-shot call is emitted as one content |
| 19 |
* frame, so the client UX is uniform (it just fills instantly instead of |
| 20 |
* progressively). The non-streaming REST /transform endpoint remains the |
| 21 |
* fallback the multi-block path uses. |
| 22 |
* |
| 23 |
* @package MxChatEditorAssistant |
| 24 |
*/ |
| 25 |
|
| 26 |
if (!defined('ABSPATH')) { |
| 27 |
exit; |
| 28 |
} |
| 29 |
|
| 30 |
class MxChat_Editor_Assistant_Stream { |
| 31 |
|
| 32 |
const AJAX_ACTION = 'mxchat_editor_assistant_stream'; |
| 33 |
const NONCE = 'mxchat_editor_assistant_stream'; |
| 34 |
|
| 35 |
public static function register() { |
| 36 |
add_action('wp_ajax_' . self::AJAX_ACTION, array(__CLASS__, 'handle_stream')); |
| 37 |
} |
| 38 |
|
| 39 |
/** |
| 40 |
* Emit a single SSE frame and flush it to the client. |
| 41 |
*/ |
| 42 |
private static function emit($payload) { |
| 43 |
echo 'data: ' . wp_json_encode($payload) . "\n\n"; |
| 44 |
if (function_exists('ob_get_level')) { |
| 45 |
while (ob_get_level() > 0) { |
| 46 |
@ob_end_flush(); |
| 47 |
} |
| 48 |
} |
| 49 |
@flush(); |
| 50 |
} |
| 51 |
|
| 52 |
private static function emit_error_and_done($message) { |
| 53 |
self::emit(array('error' => $message)); |
| 54 |
echo "data: [DONE]\n\n"; |
| 55 |
@flush(); |
| 56 |
} |
| 57 |
|
| 58 |
public static function handle_stream() { |
| 59 |
// Streaming headers up front — once we start echoing, we can't send a |
| 60 |
// normal JSON error, so all failures below are SSE error frames. |
| 61 |
if (!headers_sent()) { |
| 62 |
header('Content-Type: text/event-stream'); |
| 63 |
header('Cache-Control: no-cache'); |
| 64 |
header('X-Accel-Buffering: no'); // disable nginx proxy buffering |
| 65 |
} |
| 66 |
|
| 67 |
// Auth: same bar as the REST transform endpoint. |
| 68 |
if (!check_ajax_referer(self::NONCE, 'nonce', false)) { |
| 69 |
self::emit_error_and_done(__('Security check failed. Reload the editor and try again.', 'mxchat')); |
| 70 |
wp_die('', '', array('response' => null)); |
| 71 |
} |
| 72 |
if (!current_user_can('edit_posts')) { |
| 73 |
self::emit_error_and_done(__('You do not have permission to use the Editor Assistant.', 'mxchat')); |
| 74 |
wp_die('', '', array('response' => null)); |
| 75 |
} |
| 76 |
// Folded into core (plan-8cb0cb) as a FREE feature — no PRO license gate. |
| 77 |
// Availability is controlled by the mxchat_editor_assistant_enabled toggle |
| 78 |
// (off by default); when off, this AJAX action is never registered. |
| 79 |
|
| 80 |
$slug = isset($_POST['action_slug']) ? sanitize_key(wp_unslash($_POST['action_slug'])) : ''; |
| 81 |
$content = isset($_POST['content']) ? trim((string) wp_unslash($_POST['content'])) : ''; |
| 82 |
$locale = isset($_POST['locale']) ? sanitize_text_field(wp_unslash($_POST['locale'])) : ''; |
| 83 |
|
| 84 |
$actions = MxChat_Editor_Assistant_REST::get_actions(); |
| 85 |
if ($slug === '' || !isset($actions[$slug])) { |
| 86 |
self::emit_error_and_done(__('Unknown action.', 'mxchat')); |
| 87 |
wp_die('', '', array('response' => null)); |
| 88 |
} |
| 89 |
if ($content === '') { |
| 90 |
self::emit_error_and_done(__('Block has no text content to transform.', 'mxchat')); |
| 91 |
wp_die('', '', array('response' => null)); |
| 92 |
} |
| 93 |
if (strlen($content) > 16000) { |
| 94 |
self::emit_error_and_done(__('Block is too long (16k character cap). Split it first.', 'mxchat')); |
| 95 |
wp_die('', '', array('response' => null)); |
| 96 |
} |
| 97 |
|
| 98 |
$action = $actions[$slug]; |
| 99 |
$system_prompt = $action['system']; |
| 100 |
if (!empty($action['needs_locale'])) { |
| 101 |
$locale_label = MxChat_Editor_Assistant_REST::resolve_locale_label($locale); |
| 102 |
if ($locale_label === '') { |
| 103 |
self::emit_error_and_done(__('Target language is required for Translate.', 'mxchat')); |
| 104 |
wp_die('', '', array('response' => null)); |
| 105 |
} |
| 106 |
$system_prompt = str_replace('{LOCALE_LABEL}', $locale_label, $system_prompt); |
| 107 |
} |
| 108 |
|
| 109 |
$opts = get_option('mxchat_options', array()); |
| 110 |
$model = isset($opts['model']) ? (string) $opts['model'] : ''; |
| 111 |
if ($model === '') { |
| 112 |
self::emit_error_and_done(__('No AI model is configured in MxChat Settings. Set a model first.', 'mxchat')); |
| 113 |
wp_die('', '', array('response' => null)); |
| 114 |
} |
| 115 |
|
| 116 |
$provider = MxChat_Editor_Assistant_REST::detect_provider($model); |
| 117 |
|
| 118 |
switch ($provider) { |
| 119 |
case 'openai': |
| 120 |
self::stream_openai_compatible('https://api.openai.com/v1/chat/completions', array( |
| 121 |
'Content-Type: application/json', |
| 122 |
'Authorization: Bearer ' . self::key($opts, 'api_key'), |
| 123 |
), $model, $system_prompt, $content, self::key($opts, 'api_key'), __('OpenAI', 'mxchat')); |
| 124 |
break; |
| 125 |
|
| 126 |
case 'grok': |
| 127 |
self::stream_openai_compatible('https://api.x.ai/v1/chat/completions', array( |
| 128 |
'Content-Type: application/json', |
| 129 |
'Authorization: Bearer ' . self::key($opts, 'xai_api_key'), |
| 130 |
), $model, $system_prompt, $content, self::key($opts, 'xai_api_key'), __('Grok (xAI)', 'mxchat')); |
| 131 |
break; |
| 132 |
|
| 133 |
case 'anthropic': |
| 134 |
self::stream_anthropic($opts, $model, $system_prompt, $content); |
| 135 |
break; |
| 136 |
|
| 137 |
case 'custom': |
| 138 |
$cfg = MxChat_Editor_Assistant_REST::resolve_custom_provider($opts); |
| 139 |
if ($cfg['base_url'] === '') { |
| 140 |
self::emit_error_and_done(__('Custom provider Base URL is not configured in MxChat settings.', 'mxchat')); |
| 141 |
break; |
| 142 |
} |
| 143 |
// Convert assoc headers back to the colon-style list curl expects. |
| 144 |
$hdrs = array(); |
| 145 |
foreach ($cfg['headers'] as $k => $v) { |
| 146 |
$hdrs[] = $k . ': ' . $v; |
| 147 |
} |
| 148 |
self::stream_openai_compatible($cfg['chat_url'], $hdrs, $cfg['model'], $system_prompt, $content, $cfg['api_key'], __('custom provider', 'mxchat')); |
| 149 |
break; |
| 150 |
|
| 151 |
case 'google': |
| 152 |
default: |
| 153 |
// Gemini + anything else: no streaming branch — one-shot the |
| 154 |
// non-streaming dispatcher and emit the full result as one frame. |
| 155 |
self::stream_via_fallback($slug, $content, $locale); |
| 156 |
break; |
| 157 |
} |
| 158 |
|
| 159 |
wp_die('', '', array('response' => null)); |
| 160 |
} |
| 161 |
|
| 162 |
private static function key($opts, $name) { |
| 163 |
return isset($opts[$name]) ? trim((string) $opts[$name]) : ''; |
| 164 |
} |
| 165 |
|
| 166 |
/** |
| 167 |
* Stream from any OpenAI-compatible Chat Completions endpoint (OpenAI, Grok, |
| 168 |
* custom/Azure). Parses `choices[].delta.content` chunks. |
| 169 |
*/ |
| 170 |
private static function stream_openai_compatible($url, $headers, $model, $system_prompt, $user_content, $api_key, $provider_label) { |
| 171 |
if ($api_key === '' && stripos(implode(' ', $headers), 'api-key:') === false) { |
| 172 |
/* translators: %s: provider label */ |
| 173 |
self::emit_error_and_done(sprintf(__('%s API key is missing in MxChat settings.', 'mxchat'), $provider_label)); |
| 174 |
return; |
| 175 |
} |
| 176 |
|
| 177 |
$body = array( |
| 178 |
'model' => $model, |
| 179 |
'messages' => array( |
| 180 |
array('role' => 'system', 'content' => $system_prompt), |
| 181 |
array('role' => 'user', 'content' => $user_content), |
| 182 |
), |
| 183 |
'temperature' => 0.4, |
| 184 |
'stream' => true, |
| 185 |
); |
| 186 |
|
| 187 |
// GPT-5 reasoning models reject non-default temperature (API 400: only the |
| 188 |
// default 1 is supported). Mirror mxchat-admin-chat's send_to_openai (15bd6a) |
| 189 |
// / mxchat-basic 3.2.9's integrator exactly: send temperature 1 and route |
| 190 |
// reasoning_effort. Only triggers for gpt-5* ids, so Grok / custom OpenAI- |
| 191 |
// compatible providers keep the 0.4 default above. |
| 192 |
if (strpos($model, 'gpt-5') === 0) { |
| 193 |
$body['temperature'] = 1; |
| 194 |
$no_reasoning_models = array('gpt-5.2', 'gpt-5.1-chat-latest', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano'); |
| 195 |
if (!in_array($model, $no_reasoning_models, true)) { |
| 196 |
if ($model === 'gpt-5.1-2025-11-13') { |
| 197 |
$body['reasoning_effort'] = 'low'; |
| 198 |
} elseif ($model === 'gpt-5.5' || $model === 'gpt-5.4') { |
| 199 |
$body['reasoning_effort'] = 'none'; |
| 200 |
} elseif (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) { |
| 201 |
// plan-6fb107: gpt-5.6 rejects 'minimal' (live 400: "does not support |
| 202 |
// 'minimal'"); core uses 'low'. Mirror mxchat-basic integrator :8444. |
| 203 |
$body['reasoning_effort'] = 'low'; |
| 204 |
} else { |
| 205 |
$body['reasoning_effort'] = 'minimal'; |
| 206 |
} |
| 207 |
} |
| 208 |
} |
| 209 |
|
| 210 |
$emitted = self::run_curl_stream($url, $headers, $body, 'openai'); |
| 211 |
self::finish_stream($emitted, $provider_label); |
| 212 |
} |
| 213 |
|
| 214 |
/** |
| 215 |
* Stream from Anthropic Messages API. Parses content_block_delta text deltas. |
| 216 |
*/ |
| 217 |
private static function stream_anthropic($opts, $model, $system_prompt, $user_content) { |
| 218 |
// mxchat-basic stores the Claude/Anthropic key under 'claude_api_key' |
| 219 |
// (canonical core option). Read that first; fall back to 'anthropic_api_key'. |
| 220 |
$api_key = self::key($opts, 'claude_api_key'); |
| 221 |
if ($api_key === '') { |
| 222 |
$api_key = self::key($opts, 'anthropic_api_key'); |
| 223 |
} |
| 224 |
if ($api_key === '') { |
| 225 |
self::emit_error_and_done(__('Anthropic API key is missing in MxChat settings.', 'mxchat')); |
| 226 |
return; |
| 227 |
} |
| 228 |
$headers = array( |
| 229 |
'Content-Type: application/json', |
| 230 |
'x-api-key: ' . $api_key, |
| 231 |
'anthropic-version: 2023-06-01', |
| 232 |
); |
| 233 |
$body = array( |
| 234 |
'model' => $model, |
| 235 |
'max_tokens' => 1024, |
| 236 |
// Prompt-cache breakpoint (plan 1ff43b): the editor-assistant |
| 237 |
// instruction is a fixed template reused across requests. |
| 238 |
'system' => trim((string) $system_prompt) === '' ? $system_prompt : array( |
| 239 |
array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')), |
| 240 |
), |
| 241 |
'messages' => array( |
| 242 |
array('role' => 'user', 'content' => $user_content), |
| 243 |
), |
| 244 |
'stream' => true, |
| 245 |
); |
| 246 |
$emitted = self::run_curl_stream('https://api.anthropic.com/v1/messages', $headers, $body, 'anthropic'); |
| 247 |
self::finish_stream($emitted, __('Anthropic', 'mxchat')); |
| 248 |
} |
| 249 |
|
| 250 |
/** |
| 251 |
* Shared curl streaming core. Returns the number of content chunks emitted |
| 252 |
* (0 means nothing streamed — caller surfaces an error). |
| 253 |
*/ |
| 254 |
private static function run_curl_stream($url, $headers, $body, $format) { |
| 255 |
$emitted = 0; |
| 256 |
$ch = curl_init(); |
| 257 |
curl_setopt($ch, CURLOPT_URL, $url); |
| 258 |
curl_setopt($ch, CURLOPT_RETURNTRANSFER, false); |
| 259 |
curl_setopt($ch, CURLOPT_POST, true); |
| 260 |
curl_setopt($ch, CURLOPT_POSTFIELDS, wp_json_encode($body)); |
| 261 |
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); |
| 262 |
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); |
| 263 |
curl_setopt($ch, CURLOPT_TIMEOUT, 60); |
| 264 |
curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) use (&$emitted, $format) { |
| 265 |
$lines = explode("\n", $data); |
| 266 |
foreach ($lines as $line) { |
| 267 |
$line = trim($line); |
| 268 |
if ($line === '' || strpos($line, 'data: ') !== 0) { |
| 269 |
continue; |
| 270 |
} |
| 271 |
$json_str = substr($line, 6); |
| 272 |
if ($json_str === '[DONE]') { |
| 273 |
continue; |
| 274 |
} |
| 275 |
$json = json_decode($json_str, true); |
| 276 |
if (!is_array($json)) { |
| 277 |
continue; |
| 278 |
} |
| 279 |
if ($format === 'anthropic') { |
| 280 |
if (isset($json['type'], $json['delta']['text']) && $json['type'] === 'content_block_delta') { |
| 281 |
self::emit(array('content' => $json['delta']['text'])); |
| 282 |
$emitted++; |
| 283 |
} |
| 284 |
} else { // openai-compatible |
| 285 |
if (isset($json['choices'][0]['delta']['content'])) { |
| 286 |
self::emit(array('content' => $json['choices'][0]['delta']['content'])); |
| 287 |
$emitted++; |
| 288 |
} |
| 289 |
} |
| 290 |
} |
| 291 |
return strlen($data); |
| 292 |
}); |
| 293 |
|
| 294 |
curl_exec($ch); |
| 295 |
$http_code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); |
| 296 |
$curl_error = curl_error($ch); |
| 297 |
curl_close($ch); |
| 298 |
|
| 299 |
if ($curl_error) { |
| 300 |
self::emit(array('error' => 'Connection error: ' . $curl_error)); |
| 301 |
return $emitted; |
| 302 |
} |
| 303 |
if ($http_code !== 200 && $emitted === 0) { |
| 304 |
self::emit(array('error' => 'Provider returned HTTP ' . $http_code)); |
| 305 |
return $emitted; |
| 306 |
} |
| 307 |
return $emitted; |
| 308 |
} |
| 309 |
|
| 310 |
private static function finish_stream($emitted, $provider_label) { |
| 311 |
if ($emitted === 0) { |
| 312 |
/* translators: %s: provider label */ |
| 313 |
self::emit(array('error' => sprintf(__('%s returned no streamed content.', 'mxchat'), $provider_label))); |
| 314 |
} |
| 315 |
echo "data: [DONE]\n\n"; |
| 316 |
@flush(); |
| 317 |
} |
| 318 |
|
| 319 |
/** |
| 320 |
* Non-streaming providers (Gemini, etc.): run the same dispatcher the REST |
| 321 |
* endpoint uses and emit the full result as one frame so the client UX stays |
| 322 |
* identical. Reuses the REST class's provider calls via a minimal request. |
| 323 |
*/ |
| 324 |
private static function stream_via_fallback($slug, $content, $locale) { |
| 325 |
$request = new WP_REST_Request('POST', '/mxchat-editor/v1/transform'); |
| 326 |
$request->set_param('action', $slug); |
| 327 |
$request->set_param('content', $content); |
| 328 |
$request->set_param('locale', $locale); |
| 329 |
$result = MxChat_Editor_Assistant_REST::handle_transform($request); |
| 330 |
|
| 331 |
if (is_wp_error($result)) { |
| 332 |
self::emit(array('error' => $result->get_error_message())); |
| 333 |
} else { |
| 334 |
$data = $result->get_data(); |
| 335 |
$text = isset($data['result']) ? (string) $data['result'] : ''; |
| 336 |
if ($text === '') { |
| 337 |
self::emit(array('error' => __('Provider returned no content.', 'mxchat'))); |
| 338 |
} else { |
| 339 |
self::emit(array('content' => $text)); |
| 340 |
} |
| 341 |
} |
| 342 |
echo "data: [DONE]\n\n"; |
| 343 |
@flush(); |
| 344 |
} |
| 345 |
} |
| 346 |
|