PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.22
MxChat – AI Chatbot & Content Generation for WordPress v3.2.22
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.22, at includes/class-mxchat-editor-assistant-stream.php

346 lines 15.1 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) {
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