PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.25
MxChat – AI Chatbot & Content Generation for WordPress v3.2.25
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
mxchat-basic / includes / class-mxchat-editor-assistant-stream.php

class-mxchat-editor-assistant-stream.php in MxChat – AI Chatbot & Content Generation for WordPress 3.2.25, at includes/class-mxchat-editor-assistant-stream.php

349 lines 15.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 || strpos($model, 'gpt-6') === 0) { // gpt-6 too (plan 45c22d)
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-6-astra', 'gpt-6-sol', 'gpt-6-luna'), true)) {
201 // plan 45c22d (probed 2026-09-27): gpt-6 rejects 'minimal'; core uses 'low'.
202 $body['reasoning_effort'] = 'low';
203 } elseif (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) {
204 // plan-6fb107: gpt-5.6 rejects 'minimal' (live 400: "does not support
205 // 'minimal'"); core uses 'low'. Mirror mxchat-basic integrator :8444.
206 $body['reasoning_effort'] = 'low';
207 } else {
208 $body['reasoning_effort'] = 'minimal';
209 }
210 }
211 }
212
213 $emitted = self::run_curl_stream($url, $headers, $body, 'openai');
214 self::finish_stream($emitted, $provider_label);
215 }
216
217 /**
218 * Stream from Anthropic Messages API. Parses content_block_delta text deltas.
219 */
220 private static function stream_anthropic($opts, $model, $system_prompt, $user_content) {
221 // mxchat-basic stores the Claude/Anthropic key under 'claude_api_key'
222 // (canonical core option). Read that first; fall back to 'anthropic_api_key'.
223 $api_key = self::key($opts, 'claude_api_key');
224 if ($api_key === '') {
225 $api_key = self::key($opts, 'anthropic_api_key');
226 }
227 if ($api_key === '') {
228 self::emit_error_and_done(__('Anthropic API key is missing in MxChat settings.', 'mxchat'));
229 return;
230 }
231 $headers = array(
232 'Content-Type: application/json',
233 'x-api-key: ' . $api_key,
234 'anthropic-version: 2023-06-01',
235 );
236 $body = array(
237 'model' => $model,
238 'max_tokens' => 1024,
239 // Prompt-cache breakpoint (plan 1ff43b): the editor-assistant
240 // instruction is a fixed template reused across requests.
241 'system' => trim((string) $system_prompt) === '' ? $system_prompt : array(
242 array('type' => 'text', 'text' => $system_prompt, 'cache_control' => array('type' => 'ephemeral')),
243 ),
244 'messages' => array(
245 array('role' => 'user', 'content' => $user_content),
246 ),
247 'stream' => true,
248 );
249 $emitted = self::run_curl_stream('https://api.anthropic.com/v1/messages', $headers, $body, 'anthropic');
250 self::finish_stream($emitted, __('Anthropic', 'mxchat'));
251 }
252
253 /**
254 * Shared curl streaming core. Returns the number of content chunks emitted
255 * (0 means nothing streamed — caller surfaces an error).
256 */
257 private static function run_curl_stream($url, $headers, $body, $format) {
258 $emitted = 0;
259 $ch = curl_init();
260 curl_setopt($ch, CURLOPT_URL, $url);
261 curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
262 curl_setopt($ch, CURLOPT_POST, true);
263 curl_setopt($ch, CURLOPT_POSTFIELDS, wp_json_encode($body));
264 curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
265 curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
266 curl_setopt($ch, CURLOPT_TIMEOUT, 60);
267 curl_setopt($ch, CURLOPT_WRITEFUNCTION, function ($ch, $data) use (&$emitted, $format) {
268 $lines = explode("\n", $data);
269 foreach ($lines as $line) {
270 $line = trim($line);
271 if ($line === '' || strpos($line, 'data: ') !== 0) {
272 continue;
273 }
274 $json_str = substr($line, 6);
275 if ($json_str === '[DONE]') {
276 continue;
277 }
278 $json = json_decode($json_str, true);
279 if (!is_array($json)) {
280 continue;
281 }
282 if ($format === 'anthropic') {
283 if (isset($json['type'], $json['delta']['text']) && $json['type'] === 'content_block_delta') {
284 self::emit(array('content' => $json['delta']['text']));
285 $emitted++;
286 }
287 } else { // openai-compatible
288 if (isset($json['choices'][0]['delta']['content'])) {
289 self::emit(array('content' => $json['choices'][0]['delta']['content']));
290 $emitted++;
291 }
292 }
293 }
294 return strlen($data);
295 });
296
297 curl_exec($ch);
298 $http_code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
299 $curl_error = curl_error($ch);
300 curl_close($ch);
301
302 if ($curl_error) {
303 self::emit(array('error' => 'Connection error: ' . $curl_error));
304 return $emitted;
305 }
306 if ($http_code !== 200 && $emitted === 0) {
307 self::emit(array('error' => 'Provider returned HTTP ' . $http_code));
308 return $emitted;
309 }
310 return $emitted;
311 }
312
313 private static function finish_stream($emitted, $provider_label) {
314 if ($emitted === 0) {
315 /* translators: %s: provider label */
316 self::emit(array('error' => sprintf(__('%s returned no streamed content.', 'mxchat'), $provider_label)));
317 }
318 echo "data: [DONE]\n\n";
319 @flush();
320 }
321
322 /**
323 * Non-streaming providers (Gemini, etc.): run the same dispatcher the REST
324 * endpoint uses and emit the full result as one frame so the client UX stays
325 * identical. Reuses the REST class's provider calls via a minimal request.
326 */
327 private static function stream_via_fallback($slug, $content, $locale) {
328 $request = new WP_REST_Request('POST', '/mxchat-editor/v1/transform');
329 $request->set_param('action', $slug);
330 $request->set_param('content', $content);
331 $request->set_param('locale', $locale);
332 $result = MxChat_Editor_Assistant_REST::handle_transform($request);
333
334 if (is_wp_error($result)) {
335 self::emit(array('error' => $result->get_error_message()));
336 } else {
337 $data = $result->get_data();
338 $text = isset($data['result']) ? (string) $data['result'] : '';
339 if ($text === '') {
340 self::emit(array('error' => __('Provider returned no content.', 'mxchat')));
341 } else {
342 self::emit(array('content' => $text));
343 }
344 }
345 echo "data: [DONE]\n\n";
346 @flush();
347 }
348 }
349