PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 2.4.0
MxChat – AI Chatbot & Content Generation for WordPress v2.4.0
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 2.0.3 2.0.4 2.0.5 2.0.6 All 152 releases
← All changes | includes/class-mxchat-integrator.php +1716 -8467 3.2.172.4.0 View file →
@@ -8,284 +8,13 @@
8 8 private $prompts_options;
9 9 private $chat_count;
10 10 private $fallbackResponse;
11 11 private $productCardHtml;
12 - // plan-mxchat-20260717-03ba33 — consent-safe YouTube embed queued during RAG
13 - // retrieval when a video-backed KB entry is used as context. Emitted on the
14 - // response 'html' channel alongside productCardHtml (non-streaming path,
15 - // same constraint as product cards).
16 - private $videoEmbedHtml = '';
17 - // plan-mxchat-20260617-48a57a — function-calling UI payload capture. When a
18 - // model-invoked tool yields a UI element (generated image, woo product card,
19 - // image-search gallery), the FC loop stashes its html here so the FC outcome
20 - // handler can SURFACE it to the frontend the same way the intent path does,
21 - // instead of stripping it to text for the model (the bug: UI-bearing actions
22 - // rendered nothing under function calling).
23 - private $fc_ui_html = '';
24 - private $fc_ui_images = array();
25 - private $fc_ui_captured = false;
26 - // plan-mxchat-20260722-59bc1b — {context} placeholder support. When the
27 - // owner's system instructions carry {context}, the assembled KB block is
28 - // stashed here (instead of being appended to $context_content) and
29 - // get_system_instructions() injects it at the token's position. Null until
30 - // the per-turn KB assembly has run — the early URL-extraction call to
31 - // get_system_instructions() must NOT consume the token.
32 - private $context_kb_block = null;
33 12 private $word_handler;
34 13 private $last_similarity_analysis = null;
35 - private $current_valid_urls = [];
36 - private $last_vectorstore_error = null;
37 - private $is_streaming = false; // ADDED: Track if current request is streaming
38 - private $streaming_headers_sent = false; // Track if streaming headers have been sent
39 - private $pending_originating_page = null; // Originating page captured at session start, consumed on row insert
40 - private $current_action_instruction = null; // Success-message instruction injected into the next system context
41 - private $last_action_analysis = null; // Last action-match analysis for testing_data payloads
42 14
43 -/**
44 - * Setup streaming headers - call this right before actually streaming
45 - * This delays header setup to allow actions/forms to return JSON responses
46 - */
47 -/**
48 - * Auto-retry wrapper around wp_remote_post for chat-send provider calls.
49 - *
50 - * Retries up to twice (750ms then 2000ms backoff) when the upstream provider
51 - * returns a TRANSIENT error: WP timeout, 429, 502, 503, 504, or a provider-
52 - * specific "overloaded" / "rate limit" body string. Returns immediately on
53 - * permanent errors (401/403/404/422) so misconfiguration surfaces fast.
54 - *
55 - * Drop-in replacement for wp_remote_post — returns the same shape
56 - * (WP_Error or response array) so the caller's existing error-handling
57 - * code path is unchanged.
58 - *
59 - * STREAMING PATH NOTE: this helper is ONLY for non-streaming chat-send
60 - * paths (the *_response_openai / *_response_claude / etc functions).
61 - * For the *_stream variants, the cURL initial-connect happens inside a
62 - * read-chunks loop — retrying there safely (without re-emitting partial
63 - * stream chunks to the client) is a separate problem. Streaming paths
64 - * are NOT wrapped in this build; tracked as a follow-on.
65 - *
66 - * Honors the `mxchat_options['auto_retry_on_transient_error']` toggle
67 - * (default true). When false, behavior is identical to plain wp_remote_post.
68 - */
69 -private function mxchat_provider_call_with_retry($url, $args, $provider_hint = '') {
70 - $opts = is_array($this->options ?? null) ? $this->options : array();
71 - $enabled = !isset($opts['auto_retry_on_transient_error']) ||
72 - (string) $opts['auto_retry_on_transient_error'] !== '0';
73 15
74 - if (!$enabled) {
75 - return wp_remote_post($url, $args);
76 - }
77 -
78 - $backoffs = array(0, 750, 2000); // ms — first attempt 0, then retry waits
79 - $last_response = null;
80 -
81 - foreach ($backoffs as $i => $delay_ms) {
82 - if ($delay_ms > 0) {
83 - usleep($delay_ms * 1000);
84 - }
85 - $response = wp_remote_post($url, $args);
86 - $last_response = $response;
87 -
88 - if (!$this->mxchat_is_transient_provider_error($response, $provider_hint)) {
89 - return $response;
90 - }
91 -
92 - if (defined('WP_DEBUG') && WP_DEBUG) {
93 - $code_for_log = is_wp_error($response) ? 'wp_error:' . $response->get_error_code()
94 - : (int) wp_remote_retrieve_response_code($response);
95 - error_log(sprintf(
96 - '[MxChat] Transient provider error (provider=%s, attempt=%d/3, status=%s). %s',
97 - $provider_hint ?: 'unknown',
98 - $i + 1,
99 - $code_for_log,
100 - ($i + 1) < count($backoffs) ? 'Retrying.' : 'Giving up.'
101 - ));
102 - }
103 - }
104 -
105 - return $last_response;
106 -}
107 -
108 16 /**
109 - * Returns true if a wp_remote_post response represents a TRANSIENT
110 - * provider error worth retrying. Conservative — only retries on signals
111 - * that are very likely to clear within a few seconds.
112 - *
113 - * Transient signals:
114 - * - WP_Error with timeout / connection / dns / ssl
115 - * - HTTP 429, 502, 503, 504
116 - * - Provider-specific overload bodies (gemini "overloaded", openai
117 - * "server_error", anthropic "overloaded_error", xai/grok "Rate limit")
118 - *
119 - * NOT transient (return false — fail-fast):
120 - * - 200/2xx (success)
121 - * - 401, 403, 404, 422 (auth / config errors — retrying wastes the
122 - * budget; the user needs to fix something)
123 - * - Any other 4xx (assume permanent unless explicitly listed above)
124 - * - 5xx other than the four listed above (e.g. 500 generic server error
125 - * is often a malformed request on our side, not a transient outage)
126 - */
127 -private function mxchat_is_transient_provider_error($response, $provider_hint = '') {
128 - if (is_wp_error($response)) {
129 - $code = $response->get_error_code();
130 - return in_array($code, array('http_request_failed', 'connection_failed', 'connection_timeout'), true)
131 - || stripos((string) $response->get_error_message(), 'timed out') !== false
132 - || stripos((string) $response->get_error_message(), 'timeout') !== false;
133 - }
134 -
135 - $status = (int) wp_remote_retrieve_response_code($response);
136 - if (in_array($status, array(429, 502, 503, 504), true)) {
137 - return true;
138 - }
139 - if ($status >= 200 && $status < 300) {
140 - return false;
141 - }
142 - // Permanent 4xx that should fail fast — even with no body.
143 - if (in_array($status, array(401, 403, 404, 405, 422), true)) {
144 - return false;
145 - }
146 -
147 - // Provider-specific body inspection for the cases where the upstream
148 - // returns 200 with an error envelope (gemini does this for overload).
149 - $body = (string) wp_remote_retrieve_body($response);
150 - if ($body === '') {
151 - return false;
152 - }
153 - $lower = strtolower($body);
154 - $hint = strtolower((string) $provider_hint);
155 -
156 - if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
157 - || strpos($lower, 'high demand') !== false
158 - || strpos($lower, 'model is overloaded') !== false)) {
159 - return true;
160 - }
161 - if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
162 - || strpos($lower, '"type":"server_error"') !== false
163 - || strpos($lower, '"code":"server_error"') !== false)) {
164 - return true;
165 - }
166 - if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
167 - || strpos($lower, 'overloaded_error') !== false)) {
168 - return true;
169 - }
170 - if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
171 - return true;
172 - }
173 -
174 - return false;
175 -}
176 -
177 -/**
178 - * Streaming-path classifier: same rules as mxchat_is_transient_provider_error
179 - * but takes a raw (http_code, body, provider_hint, curl_errno) tuple as
180 - * captured during a cURL streaming exec. cURL's WRITEFUNCTION/HEADERFUNCTION
181 - * collect status separately from a plain wp_remote_post array shape, so the
182 - * non-streaming helper above can't be called directly. This delegate keeps
183 - * the classification rules identical across both paths.
184 - */
185 -private function mxchat_is_transient_provider_error_raw($http_code, $body, $provider_hint = '', $curl_errno = 0) {
186 - if ($curl_errno) {
187 - // cURL transport-level error (timeout, connection failure, DNS, etc.)
188 - // Match the same WP_Error timeout/connection signals the array variant treats as transient.
189 - return in_array($curl_errno, array(
190 - CURLE_OPERATION_TIMEDOUT,
191 - CURLE_COULDNT_CONNECT,
192 - CURLE_COULDNT_RESOLVE_HOST,
193 - CURLE_SSL_CONNECT_ERROR,
194 - CURLE_GOT_NOTHING,
195 - CURLE_SEND_ERROR,
196 - CURLE_RECV_ERROR,
197 - ), true);
198 - }
199 -
200 - $status = (int) $http_code;
201 - if (in_array($status, array(429, 502, 503, 504), true)) {
202 - return true;
203 - }
204 - if ($status >= 200 && $status < 300) {
205 - return false;
206 - }
207 - if (in_array($status, array(401, 403, 404, 405, 422), true)) {
208 - return false;
209 - }
210 -
211 - $body = (string) $body;
212 - if ($body === '') {
213 - return false;
214 - }
215 - $lower = strtolower($body);
216 - $hint = strtolower((string) $provider_hint);
217 -
218 - if ($hint === 'gemini' && (strpos($lower, 'overloaded') !== false
219 - || strpos($lower, 'high demand') !== false
220 - || strpos($lower, 'model is overloaded') !== false)) {
221 - return true;
222 - }
223 - if ($hint === 'openai' && (strpos($lower, 'rate limit reached') !== false
224 - || strpos($lower, '"type":"server_error"') !== false
225 - || strpos($lower, '"code":"server_error"') !== false)) {
226 - return true;
227 - }
228 - if ($hint === 'anthropic' && (strpos($lower, '"type":"overloaded_error"') !== false
229 - || strpos($lower, 'overloaded_error') !== false)) {
230 - return true;
231 - }
232 - if (($hint === 'xai' || $hint === 'grok') && strpos($lower, 'rate limit') !== false) {
233 - return true;
234 - }
235 -
236 - return false;
237 -}
238 -
239 -/**
240 - * Whether transient-error auto-retry is enabled in admin settings.
241 - * Default true unless explicitly set to '0'. Used by both wp_remote_post
242 - * (mxchat_provider_call_with_retry) and cURL streaming paths.
243 - */
244 -private function mxchat_retry_enabled() {
245 - $opts = is_array($this->options ?? null) ? $this->options : array();
246 - return !isset($opts['auto_retry_on_transient_error']) ||
247 - (string) $opts['auto_retry_on_transient_error'] !== '0';
248 -}
249 -
250 -private function setup_streaming_headers() {
251 - if ($this->streaming_headers_sent || headers_sent()) {
252 - return false;
253 - }
254 -
255 - // Headers MUST be set BEFORE the buffers are torn down: flushing a
256 - // buffer that holds any stray output commits the response and turns
257 - // every later header() into a logged no-op — dropping all four SSE
258 - // headers, including the X-Accel-Buffering that stops nginx-fronted
259 - // hosts from de-streaming the reply (plan fe130d).
260 - header('Content-Type: text/event-stream');
261 - header('Cache-Control: no-cache');
262 - header('Connection: keep-alive');
263 - header('X-Accel-Buffering: no');
264 -
265 - // Dev-mode diagnostic: with the reorder, stray buffered bytes become
266 - // the first bytes of the SSE stream — record what they are so a future
267 - // switch to ob_end_clean() can be decided on evidence (fe130d follow-up).
268 - if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE && ob_get_level() > 0) {
269 - $buffered = ob_get_contents();
270 - if (is_string($buffered) && $buffered !== '') {
271 - error_log('MxChat SSE teardown: output buffer held ' . strlen($buffered) . ' byte(s): ' . substr($buffered, 0, 200));
272 - }
273 - }
274 -
275 - // Disable output buffering
276 - while (ob_get_level()) {
277 - ob_end_flush();
278 - }
279 -
280 - ob_implicit_flush(true);
281 - flush();
282 -
283 - $this->streaming_headers_sent = true;
284 - return true;
285 -}
286 -
287 -/**
288 17 * Class constructor
289 18 */
290 19 public function __construct() {
291 20 $this->options = get_option('mxchat_options');
@@ -314,15 +43,8 @@
314 43 add_action('wp_ajax_nopriv_mxchat_fetch_new_messages', array($this, 'mxchat_fetch_new_messages'));
315 44
316 45 // Rate limit action - notice we removed the old schedule setup
317 46 add_action('mxchat_reset_rate_limits', array($this, 'mxchat_reset_rate_limits'));
318 -
319 - // Self-heal: if the reset event is ever lost (cron row cleared, botched
320 - // migration, deactivate/reactivate race), an admin-context request brings it
321 - // back. Cheap by construction: 60s transient guard + early return when the
322 - // event is already scheduled. Without this, a lost event with the fallback
323 - // flag unset leaves visitors rate-limited forever.
324 - add_action('admin_init', array($this, 'setup_rate_limit_cron_jobs'));
325 47
326 48 // File upload and handling actions
327 49 add_action('wp_ajax_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
328 50 add_action('wp_ajax_nopriv_mxchat_upload_pdf', [$this, 'handle_pdf_upload']);
@@ -359,97 +81,13 @@
359 81 // Add chat mode checking actions
360 82 add_action('wp_ajax_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
361 83 add_action('wp_ajax_nopriv_mxchat_get_current_chat_mode', array($this, 'mxchat_get_current_chat_mode'));
362 84
363 - // Nonce refresh for page-cache compatibility (WP Rocket, LiteSpeed, etc.)
364 - add_action('wp_ajax_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
365 - add_action('wp_ajax_nopriv_mxchat_refresh_nonce', array($this, 'mxchat_refresh_nonce'));
366 -
367 - // Auto-email transcript action
368 - add_action('mxchat_send_delayed_transcript', array($this, 'mxchat_send_delayed_transcript'), 10, 1);
369 -
370 85 add_filter('mxchat_check_actions_only', array($this, 'check_actions_for_addons'), 10, 4);
371 86
372 87
373 88 }
374 89
375 -/**
376 - * Return a fresh nonce so cached pages can replace the stale one.
377 - * With `with_settings`, also returns the current behavior-gate settings so
378 - * the widget can correct stale inline-localized values (plan-32db95).
379 - */
380 -public function mxchat_refresh_nonce() {
381 - nocache_headers();
382 - $payload = array('nonce' => wp_create_nonce('mxchat_chat_nonce'));
383 - if (!empty($_REQUEST['with_settings'])) {
384 - $payload['settings'] = $this->get_dynamic_widget_settings(true);
385 - }
386 - wp_send_json_success($payload);
387 -}
388 -
389 -/**
390 - * Behavior-gate settings the widget may re-fetch at runtime (plan-32db95).
391 - *
392 - * Every widget setting ships inline in page HTML via wp_localize_script, so
393 - * full-page caches (host caches, WP Rocket, LiteSpeed, W3TC, FlyingPress,
394 - * WP Super Cache, Cloudflare APO, the browser itself) keep serving a stale
395 - * snapshot after an admin changes a setting. MxChat_Cache_Purge clears the
396 - * caches PHP can reach; this payload covers the rest — the widget requests
397 - * it on first open (via the nonce-refresh endpoints) and merges it over
398 - * `mxchatChat`, the same distrust-cached-HTML pattern the 3.2.7 per-request
399 - * nonce uses.
400 - *
401 - * Behavior gates + labels ONLY — colors stay inline because they're also
402 - * server-inline-styled, and a runtime swap would visibly flash.
403 - *
404 - * Both wp_localize_script blocks merge this exact array, so the inline and
405 - * refreshed payloads cannot drift.
406 - *
407 - * @param bool $fresh Re-read mxchat_options from the DB (endpoint paths)
408 - * instead of trusting the instance copy.
409 - * @return array
410 - */
411 -public function get_dynamic_widget_settings($fresh = false) {
412 - $options = $fresh ? get_option('mxchat_options', array()) : $this->options;
413 - if (!is_array($options)) {
414 - $options = array();
415 - }
416 - return array(
417 - 'model' => isset($options['model']) ? $options['model'] : 'gpt-5.6-sol',
418 - 'enable_streaming_toggle' => isset($options['enable_streaming_toggle']) ? $options['enable_streaming_toggle'] : 'on',
419 - 'rate_limit_message' => $options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
420 - 'chat_toolbar_toggle' => $options['chat_toolbar_toggle'] ?? 'off',
421 - 'print_button_enabled' => $options['print_button_enabled'] ?? 'on',
422 - 'print_button_label' => esc_html__('Download Transcript', 'mxchat'),
423 - // "Start new chat" header-menu item (plan ac2e81). Default OFF.
424 - 'reset_chat_enabled' => $options['reset_chat_enabled'] ?? 'off',
425 - 'reset_chat_label' => !empty($options['reset_chat_label']) ? esc_html($options['reset_chat_label']) : esc_html__('Start new chat', 'mxchat'),
426 - 'reset_chat_confirm' => esc_html__('Start a new chat? This clears the current conversation.', 'mxchat'),
427 - 'stop_button_label' => esc_html__('Stop response', 'mxchat'),
428 - 'print_header_title' => esc_html(get_bloginfo('name')) . ' — ' . esc_html__('Chat transcript', 'mxchat'),
429 - // Emit 'on'/'off' STRINGS, never booleans: wp_localize_script casts
430 - // scalars to string, and (string) false === '' — which the widget's
431 - // old gate read as enabled (plan-4bba64). The filter keeps its
432 - // boolean contract; only the emitted value is stringified.
433 - 'satisfaction_rating_enabled' => apply_filters(
434 - 'mxchat_satisfaction_rating_enabled',
435 - ($options['satisfaction_rating_enabled'] ?? 'off') === 'on'
436 - ) ? 'on' : 'off',
437 - 'satisfaction_rating_idle_seconds' => max(5, min(600, intval($options['satisfaction_rating_idle_seconds'] ?? 60))),
438 - 'satisfaction_rating_copy' => array(
439 - 'question' => !empty($options['satisfaction_rating_question']) ? esc_html($options['satisfaction_rating_question']) : esc_html__('Was this helpful?', 'mxchat'),
440 - 'helpful' => esc_html__('Helpful', 'mxchat'),
441 - 'not_helpful' => esc_html__('Not helpful', 'mxchat'),
442 - 'dismiss' => esc_html__('Dismiss', 'mxchat'),
443 - 'thanks' => !empty($options['satisfaction_rating_thanks']) ? esc_html($options['satisfaction_rating_thanks']) : esc_html__('Thanks! Anything we should improve? (optional)', 'mxchat'),
444 - 'placeholder' => !empty($options['satisfaction_rating_placeholder']) ? esc_html($options['satisfaction_rating_placeholder']) : esc_html__('Tell us what could be better…', 'mxchat'),
445 - 'send' => esc_html__('Send', 'mxchat'),
446 - 'skip' => esc_html__('Skip', 'mxchat'),
447 - 'saved' => !empty($options['satisfaction_rating_saved']) ? esc_html($options['satisfaction_rating_saved']) : esc_html__('Thanks for the feedback.', 'mxchat'),
448 - ),
449 - );
450 -}
451 -
452 90 // In your core plugin's check_actions_for_addons method:
453 91 public function check_actions_for_addons($default, $message, $user_id, $session_id) {
454 92 //error_log('MxChat Core: check_actions_for_addons called with message: ' . $message);
455 93
@@ -471,23 +109,9 @@
471 109 wp_send_json_error(['message' => esc_html__('Session ID missing.', 'mxchat')]);
472 110 wp_die();
473 111 }
474 112
475 - $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
476 -
477 - // SECURITY FIX: Verify session ownership before retrieving data
478 - // If IP/user changed, signal frontend to reset session instead of blocking
479 - $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
480 -
481 - // Check if this session has an owner recorded
482 - $session_owner = get_option("mxchat_session_owner_{$session_id}");
483 -
484 - // Update session owner if it changed (e.g. IP changed due to network switch)
485 - // The session ID itself is the authentication — if the client has it, they own it
486 - if (!$session_owner || $session_owner !== $current_user_identifier) {
487 - update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
488 - }
489 -
113 + $session_id = sanitize_text_field($_POST['session_id']);
490 114 $history = get_option("mxchat_history_{$session_id}", []); // Retrieve stored history
491 115 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai'); // Get current chat mode
492 116
493 117 if (empty($history)) {
@@ -504,25 +128,11 @@
504 128 'chat_mode' => $chat_mode
505 129 ]);
506 130 wp_die();
507 131 }
508 -private function mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp = 0) {
132 +
133 +private function mxchat_fetch_conversation_history_for_ai($session_id) {
509 134 $history = get_option("mxchat_history_{$session_id}", []);
510 -
511 - // Check persistence setting - when OFF, only include messages from current page load
512 - $options = get_option('mxchat_options', []);
513 - $persistence_enabled = isset($options['chat_persistence_toggle']) && $options['chat_persistence_toggle'] === 'on';
514 -
515 - // Filter history when persistence is OFF to match what the user sees
516 - if (!$persistence_enabled && $session_start_timestamp > 0) {
517 - $history = array_filter($history, function($entry) use ($session_start_timestamp) {
518 - // Include messages from this page load onwards
519 - return isset($entry['timestamp']) && $entry['timestamp'] >= $session_start_timestamp;
520 - });
521 - // Re-index array after filtering
522 - $history = array_values($history);
523 - }
524 -
525 135 $formatted_history = [];
526 136
527 137 // Adjusted for code-heavy conversations
528 138 $max_tokens = 120000; // Context window size
@@ -596,17 +206,8 @@
596 206
597 207 public function register_routes() {
598 208 //error_log(esc_html__('Registering MxChat REST routes', 'mxchat'));
599 209
600 - // Per-request chat-send nonce endpoint — issues a fresh nonce on demand
601 - // so the chat widget never depends on a stale nonce embedded in cached HTML.
602 - // Public (no auth), rate-limited (1 call / IP / second via a transient).
603 - register_rest_route('mxchat/v1', '/nonce', [
604 - 'methods' => 'GET',
605 - 'callback' => [$this, 'mxchat_issue_chat_send_nonce'],
606 - 'permission_callback' => '__return_true',
607 - ]);
608 -
609 210 register_rest_route('mxchat/v1', '/stream', [
610 211 'methods' => 'GET',
611 212 'callback' => [$this, 'mxchat_stream_events'],
612 213 'permission_callback' => [$this, 'verify_chat_session'],
@@ -629,105 +230,12 @@
629 230 'callback' => [$this, 'handle_slack_messages'],
630 231 'permission_callback' => [$this, 'verify_slack_request'],
631 232 ]);
632 233
633 - // Telegram webhook endpoint
634 - register_rest_route('mxchat/v1', '/telegram-webhook', [
635 - 'methods' => 'POST',
636 - 'callback' => [$this, 'handle_telegram_webhook'],
637 - 'permission_callback' => [$this, 'verify_telegram_request'],
638 - ]);
639 -
640 234 //error_log(esc_html__('MxChat REST routes registered', 'mxchat'));
641 235 }
642 236
643 237 /**
644 - * Issue a fresh per-request nonce for chat-send. Returned to the widget which
645 - * caches it for the session and includes it on every chat-send / stream-send /
646 - * upload call. By moving the nonce out of inline `window.mxchatChat = {...}` HTML
647 - * we eliminate the entire class of "first-message Access denied" failures that
648 - * plague WP installs behind a full-page cache (WP Rocket, LiteSpeed, FlyingPress,
649 - * W3 Total Cache, Cloudflare APO) — the nonce is never cached because it never
650 - * lives in the HTML body.
651 - *
652 - * Public endpoint. Rate-limited to 1 call / IP / 1s via a transient so a single
653 - * client browser can't be used to flood the nonce-issuance path.
654 - *
655 - * Nonce action: `mxchat_chat_send` (new). The chat-send AJAX handlers accept
656 - * BOTH this action AND the legacy `mxchat_chat_nonce` action for a 30-day
657 - * backwards-compat window so cached pages still in users' browsers don't break
658 - * mid-session.
659 - *
660 - * @since 3.2.7
661 - */
662 -public function mxchat_issue_chat_send_nonce(WP_REST_Request $request) {
663 - $ip = '';
664 - if (!empty($_SERVER['REMOTE_ADDR'])) {
665 - $ip = preg_replace('#[^0-9a-fA-F:\.]#', '', wp_unslash((string) $_SERVER['REMOTE_ADDR']));
666 - }
667 - if ($ip !== '') {
668 - // Best-effort rate limit. WP transients with sub-second TTL are racy
669 - // (parallel bursts can squeak through before set_transient completes);
670 - // we use 2s to make the gate slightly more reliable. Real production
671 - // rate-limiting at sub-second granularity needs Redis or DB row locks
672 - // — out of scope for this endpoint, which is already cheap.
673 - $key = 'mxchat_nonce_rl_' . md5($ip);
674 - if (get_transient($key)) {
675 - return new WP_REST_Response(array(
676 - 'error' => 'rate_limited',
677 - 'message' => __('Too many nonce requests. Try again shortly.', 'mxchat'),
678 - ), 429);
679 - }
680 - set_transient($key, 1, 2);
681 - }
682 -
683 - // The widget calls this endpoint without an X-WP-Nonce header, so WordPress does not
684 - // honor the auth cookie and the request runs as uid=0 even for logged-in users. That
685 - // makes wp_create_nonce() bind the nonce to uid=0, which then fails wp_verify_nonce()
686 - // at admin-ajax (which runs as the real uid) -> logged-in users get a 403 on upload.
687 - // Resolve the real user from the logged_in cookie so the nonce binds to the correct uid.
688 - if ( ! is_user_logged_in() ) {
689 - $maybe_uid = wp_validate_auth_cookie( '', 'logged_in' );
690 - if ( $maybe_uid ) {
691 - wp_set_current_user( $maybe_uid );
692 - }
693 - }
694 -
695 - $payload = array(
696 - 'nonce' => wp_create_nonce('mxchat_chat_send'),
697 - 'expires_in' => 86400, // WP nonces live 24h; widget caches for 12h conservatively.
698 - );
699 -
700 - // plan-32db95: the widget's first-open refresh asks for current behavior
701 - // settings in the same round-trip, so stale inline-localized values on
702 - // cached pages get corrected without a second request. All values in
703 - // this payload already ship in public page HTML — nothing sensitive.
704 - if ($request->get_param('with_settings')) {
705 - $payload['settings'] = $this->get_dynamic_widget_settings(true);
706 - }
707 -
708 - return new WP_REST_Response($payload, 200);
709 -}
710 -
711 -/**
712 - * Verify a chat-send nonce. Accepts BOTH the new `mxchat_chat_send` action
713 - * (issued by /wp-json/mxchat/v1/nonce) AND the legacy `mxchat_chat_nonce`
714 - * action (inline-localized in older cached HTML). The legacy acceptance is
715 - * a 30-day backwards-compat window — to be removed in a follow-up release
716 - * after 2026-06-27.
717 - *
718 - * @param string $posted_nonce
719 - * @return bool
720 - */
721 -public static function mxchat_verify_chat_send_nonce($posted_nonce) {
722 - if (!is_string($posted_nonce) || $posted_nonce === '') {
723 - return false;
724 - }
725 - return (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_send')
726 - || (bool) wp_verify_nonce($posted_nonce, 'mxchat_chat_nonce');
727 -}
728 -
729 -/**
730 238 * Verify valid chat session
731 239 */
732 240 public function verify_chat_session($request) {
733 241 $session_id = $request->get_param('session_id');
@@ -763,11 +271,10 @@
763 271 //error_log(esc_html__('Slack request timestamp too old', 'mxchat'));
764 272 return false;
765 273 }
766 274
767 - // Get raw request body from the WP_REST_Request object
768 - // (php://input may already be consumed by WordPress at this point)
769 - $request_body = $request->get_body();
275 + // Get raw request body
276 + $request_body = file_get_contents('php://input');
770 277
771 278 // Create the signature base string
772 279 $sig_basestring = "v0:{$timestamp}:{$request_body}";
773 280
@@ -776,99 +283,14 @@
776 283
777 284 // Compare signatures
778 285 return hash_equals($my_signature, $slack_signature);
779 286 }
780 -
781 -/**
782 - * Verify request is coming from Telegram.
783 - *
784 - * @param WP_REST_Request $request
785 - * @return bool True if valid, false otherwise.
786 - */
787 -public function verify_telegram_request($request) {
788 - $secret_token = $this->options['telegram_webhook_secret'] ?? '';
789 -
790 - //error_log('[MxChat Telegram DEBUG] verify_telegram_request called');
791 - //error_log('[MxChat Telegram DEBUG] Stored secret: ' . (empty($secret_token) ? 'EMPTY' : substr($secret_token, 0, 10) . '...'));
792 -
793 - if (empty($secret_token)) {
794 - // No secret configured (legacy setup). Do NOT fail open to the whole
795 - // internet — that lets an unauthenticated caller write agent-branded
796 - // messages. Fall back to verifying the request originates from
797 - // Telegram's published webhook IP ranges so existing no-secret installs
798 - // keep working while an arbitrary-internet caller is blocked. Setting a
799 - // real secret (see the admin notice) is the recommended path.
800 - // (plan-0c17b5)
801 - $peer = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
802 - if ($this->mxchat_ip_in_telegram_ranges($peer)) {
803 - return true;
804 - }
805 - error_log('MxChat: Telegram webhook has no secret configured and the request '
806 - . 'is not from a Telegram IP range; rejected. Set a webhook secret to secure it.');
807 - return false;
808 - }
809 -
810 - // Telegram sends the secret token in the X-Telegram-Bot-Api-Secret-Token header
811 - $request_token = $request->get_header('X-Telegram-Bot-Api-Secret-Token');
812 -
813 - //error_log('[MxChat Telegram DEBUG] Request token: ' . (empty($request_token) ? 'EMPTY' : substr($request_token, 0, 10) . '...'));
814 -
815 - if (empty($request_token)) {
816 - //error_log('[MxChat Telegram DEBUG] Request rejected: No token in header');
817 - return false;
818 - }
819 -
820 - // Timing-safe comparison
821 - $result = hash_equals($secret_token, $request_token);
822 - //error_log('[MxChat Telegram DEBUG] Token comparison result: ' . ($result ? 'MATCH' : 'MISMATCH'));
823 - return $result;
824 -}
825 -
826 -/**
827 - * Whether $ip falls within Telegram's published webhook IPv4 ranges
828 - * (149.154.160.0/20 and 91.108.4.0/22). Used as an authenticity fallback for
829 - * the Telegram webhook when no secret token is configured, so a legacy
830 - * no-secret install keeps working without failing open to the entire internet.
831 - *
832 - * Uses the real TCP peer (REMOTE_ADDR); a spoofable X-Forwarded-For is NOT
833 - * consulted. Behind a reverse proxy / CDN that rewrites REMOTE_ADDR this may
834 - * not match — which is exactly why configuring a real webhook secret is the
835 - * recommended path. (plan-0c17b5)
836 - *
837 - * @param string $ip Candidate IPv4 address.
838 - * @return bool
839 - */
840 -private function mxchat_ip_in_telegram_ranges($ip) {
841 - if (!is_string($ip) || $ip === '' || filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false) {
842 - return false;
843 - }
844 - $ip_long = ip2long($ip);
845 - if ($ip_long === false) {
846 - return false;
847 - }
848 - $ranges = array(
849 - array('149.154.160.0', 20),
850 - array('91.108.4.0', 22),
851 - );
852 - foreach ($ranges as $range) {
853 - $subnet_long = ip2long($range[0]);
854 - if ($subnet_long === false) {
855 - continue;
856 - }
857 - $mask = (0xFFFFFFFF << (32 - $range[1])) & 0xFFFFFFFF;
858 - if (($ip_long & $mask) === ($subnet_long & $mask)) {
859 - return true;
860 - }
861 - }
862 - return false;
863 -}
864 -
865 287 public function mxchat_stream_events(WP_REST_Request $request) {
866 288 header('Content-Type: text/event-stream');
867 289 header('Cache-Control: no-cache');
868 290 header('Connection: keep-alive');
869 291
870 - $session_id = MxChat_Utils::sanitize_session_id($request->get_param('session_id'));
292 + $session_id = sanitize_text_field($request->get_param('session_id'));
871 293 $last_seen_id = sanitize_text_field($request->get_param('last_seen_id')) ?: '';
872 294
873 295 if (empty($session_id)) {
874 296 echo esc_html__("event: error\ndata: ", 'mxchat') . esc_html__('Missing session_id', 'mxchat') . "\n\n";
@@ -896,9 +318,9 @@
896 318
897 319
898 320
899 321
900 -private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null, $rag_context = null) {
322 +private function mxchat_save_chat_message($session_id, $role, $message, $originating_page = null) {
901 323 global $wpdb;
902 324 $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
903 325 //error_log("[DEBUG] mxchat_save_chat_message -> START for session_id: {$session_id}, role: {$role}");
904 326
@@ -910,26 +332,14 @@
910 332 $session_id
911 333 ));
912 334 $is_new_session = ($existing_messages == 0);
913 335
914 - // Log for debugging
336 + // NEW: Log for debugging
915 337 if ($is_new_session) {
916 338 //error_log("[DEBUG] This is a NEW session - first message");
917 339 }
918 340 }
919 341
920 - // SECURITY FIX: Set session ownership for new sessions
921 - if ($is_new_session && $role === 'user') {
922 - $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
923 - $session_owner_key = "mxchat_session_owner_{$session_id}";
924 -
925 - // Only set ownership if not already set
926 - if (!get_option($session_owner_key)) {
927 - update_option($session_owner_key, $current_user_identifier, 'no');
928 - //error_log("[DEBUG] Set session ownership for {$session_id} to {$current_user_identifier}");
929 - }
930 - }
931 -
932 342 // 1) Extract agent name if present
933 343 $agent_name = '';
934 344 if (preg_match('/^Agent: (.*?) - /', $message, $matches)) {
935 345 $agent_name = $matches[1];
@@ -961,9 +371,9 @@
961 371 $email_option_key = "mxchat_email_{$session_id}";
962 372 $saved_email = get_option($email_option_key);
963 373 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for email_option_key: {$email_option_key}, found: {$saved_email}");
964 374
965 - // Check for a saved name in wp_options
375 + // NEW: Check for a saved name in wp_options
966 376 $name_option_key = "mxchat_name_{$session_id}";
967 377 $saved_name = get_option($name_option_key);
968 378 //error_log("[DEBUG] mxchat_save_chat_message -> Checking wp_options for name_option_key: {$name_option_key}, found: {$saved_name}");
969 379
@@ -1006,9 +416,9 @@
1006 416 $insert_data = [
1007 417 'user_id' => $user_id,
1008 418 'user_identifier'=> $user_identifier,
1009 419 'user_email' => $saved_email ?: $user_email,
1010 - 'user_name' => $saved_name ?: '', // Add name to insert data
420 + 'user_name' => $saved_name ?: '', // NEW: Add name to insert data
1011 421 'session_id' => $session_id,
1012 422 'role' => $role,
1013 423 'message' => $message,
1014 424 'timestamp' => current_time('mysql', 1),
@@ -1034,11 +444,10 @@
1034 444 $insert_data['originating_page_title'] = $this->pending_originating_page['title'] ?? '';
1035 445
1036 446 //error_log("[DEBUG] Setting originating page from pending_originating_page: " . $this->pending_originating_page['url']);
1037 447
1038 - // Clear after using (= null, not unset(): unset() undeclares the property
1039 - // and the next assignment recreates it dynamic, re-triggering the PHP 8.2 deprecation)
1040 - $this->pending_originating_page = null;
448 + // Clear after using
449 + unset($this->pending_originating_page);
1041 450 }
1042 451 // Fallback to HTTP_REFERER if nothing else is available
1043 452 else if (isset($_SERVER['HTTP_REFERER'])) {
1044 453 $referer_url = esc_url_raw($_SERVER['HTTP_REFERER']);
@@ -1073,17 +482,9 @@
1073 482 $insert_data['originating_page_title'] = $stored_originating['title'] ?? '';
1074 483 }
1075 484 }
1076 485 }
1077 -
1078 - // Add RAG context if provided (for bot messages)
1079 - if ($rag_context !== null && $role === 'bot') {
1080 - $rag_context_column_exists = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'rag_context'");
1081 - if ($rag_context_column_exists) {
1082 - $insert_data['rag_context'] = is_array($rag_context) ? wp_json_encode($rag_context) : $rag_context;
1083 - }
1084 - }
1085 -
486 +
1086 487 $wpdb->insert($table_name, $insert_data);
1087 488 //error_log("[DEBUG] mxchat_save_chat_message -> Inserted message into DB. row_id: {$wpdb->insert_id}, data: " . print_r($insert_data, true));
1088 489
1089 490 // 9) Send notification email if this is the first user message in a new session
@@ -1094,17 +495,11 @@
1094 495 'ip' => $_SERVER['REMOTE_ADDR']
1095 496 ));
1096 497 }
1097 498
1098 - // 10) Schedule delayed transcript email if enabled and message is from user
1099 - if ($wpdb->insert_id && $role === 'user') {
1100 - $this->schedule_delayed_transcript_email($session_id);
1101 - }
1102 -
1103 499 //error_log("[DEBUG] mxchat_save_chat_message -> END for session_id: {$session_id}");
1104 500 return $message_id;
1105 501 }
1106 -
1107 502 private function send_new_chat_notification($session_id, $user_info = array()) {
1108 503 $options = get_option('mxchat_transcripts_options');
1109 504
1110 505 // Check if notifications are enabled
@@ -1147,235 +542,32 @@
1147 542 // Send email
1148 543 return wp_mail($to, $subject, $message);
1149 544 }
1150 545
1151 -/**
1152 - * Schedule delayed transcript email for a session
1153 - * Reschedules if a new user message is received
1154 - */
1155 -private function schedule_delayed_transcript_email($session_id) {
1156 - $options = get_option('mxchat_transcripts_options');
1157 -
1158 - // Check if auto-email is enabled
1159 - if (empty($options['mxchat_auto_email_transcript_enabled'])) {
1160 - return;
1161 - }
1162 -
1163 - // Get notification email
1164 - $email = !empty($options['mxchat_notification_email']) ?
1165 - $options['mxchat_notification_email'] :
1166 - get_option('admin_email');
1167 -
1168 - if (!is_email($email)) {
1169 - return;
1170 - }
1171 -
1172 - // Get delay in minutes (default 30)
1173 - $delay_minutes = isset($options['mxchat_auto_email_transcript_delay']) ?
1174 - intval($options['mxchat_auto_email_transcript_delay']) : 30;
1175 -
1176 - // Clear any existing scheduled event for this session
1177 - $hook = 'mxchat_send_delayed_transcript';
1178 - $args = array($session_id);
1179 - $timestamp = wp_next_scheduled($hook, $args);
1180 -
1181 - if ($timestamp) {
1182 - wp_unschedule_event($timestamp, $hook, $args);
1183 - }
1184 -
1185 - // Schedule new event
1186 - $schedule_time = time() + ($delay_minutes * 60);
1187 - wp_schedule_single_event($schedule_time, $hook, $args);
1188 -}
1189 -
1190 -/**
1191 - * Check if chat messages contain contact information (email or phone number)
1192 - *
1193 - * @param array $messages Array of message objects with 'message' property
1194 - * @param object|null $session_data Session data object with user_email property
1195 - * @return bool True if contact info found, false otherwise
1196 - */
1197 -private function chat_contains_contact_info($messages, $session_data = null) {
1198 - // Check if session already has a stored email
1199 - if ($session_data && !empty($session_data->user_email)) {
1200 - return true;
1201 - }
1202 -
1203 - // Email regex pattern
1204 - $email_pattern = '/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/';
1205 -
1206 - // Phone number patterns (covers various formats including international, WhatsApp style)
1207 - // Matches: +1234567890, (123) 456-7890, 123-456-7890, 123.456.7890, 1234567890, +1 234 567 8900, etc.
1208 - $phone_pattern = '/(?:\+?\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{2,4}[-.\s]?\d{2,4}(?:[-.\s]?\d{1,4})?/';
1209 -
1210 - // Only check user messages (not assistant responses)
1211 - foreach ($messages as $msg) {
1212 - if ($msg->role !== 'user') {
1213 - continue;
1214 - }
1215 -
1216 - $message_text = $msg->message;
1217 -
1218 - // Check for email
1219 - if (preg_match($email_pattern, $message_text)) {
1220 - return true;
1221 - }
1222 -
1223 - // Check for phone number (must be at least 7 digits total to avoid false positives)
1224 - if (preg_match($phone_pattern, $message_text, $matches)) {
1225 - // Count actual digits to avoid matching short numbers
1226 - $digits_only = preg_replace('/\D/', '', $matches[0]);
1227 - if (strlen($digits_only) >= 7) {
1228 - return true;
1229 - }
1230 - }
1231 - }
1232 -
1233 - return false;
1234 -}
1235 -
1236 -/**
1237 - * Send the delayed transcript email with .txt attachment
1238 - */
1239 -public function mxchat_send_delayed_transcript($session_id) {
1240 - global $wpdb;
1241 -
1242 - // plan-mxchat-20260731-d42bec — this is the one place a session id becomes a
1243 - // filesystem path segment (see the $temp_file build below), so validate here
1244 - // too even though intake is now validated. This runs from a scheduled event,
1245 - // so its argument comes from whatever was stored at schedule time rather than
1246 - // straight from the current request.
1247 - $session_id = MxChat_Utils::sanitize_session_id($session_id);
1248 - if ($session_id === '') {
1249 - return false;
1250 - }
1251 -
1252 - $options = get_option('mxchat_transcripts_options');
1253 -
1254 - // Get notification email
1255 - $to = !empty($options['mxchat_notification_email']) ?
1256 - $options['mxchat_notification_email'] :
1257 - get_option('admin_email');
1258 -
1259 - if (!is_email($to)) {
1260 - return false;
1261 - }
1262 -
1263 - // Get all messages for this session
1264 - $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1265 - $messages = $wpdb->get_results($wpdb->prepare(
1266 - "SELECT role, message, timestamp FROM {$table_name}
1267 - WHERE session_id = %s
1268 - ORDER BY timestamp ASC",
1269 - $session_id
1270 - ));
1271 -
1272 - if (empty($messages)) {
1273 - return false;
1274 - }
1275 -
1276 - // Get session metadata
1277 - $sessions_table = $wpdb->prefix . 'mxchat_sessions';
1278 - $session_data = $wpdb->get_row($wpdb->prepare(
1279 - "SELECT * FROM {$sessions_table} WHERE session_id = %s",
1280 - $session_id
1281 - ));
1282 -
1283 - // Check if contact info is required and if it's present
1284 - $require_contact = !empty($options['mxchat_auto_email_transcript_require_contact']);
1285 - if ($require_contact && !$this->chat_contains_contact_info($messages, $session_data)) {
1286 - // Contact info required but not found - skip sending
1287 - return false;
1288 - }
1289 -
1290 - // Build transcript content
1291 - $transcript_content = "Chat Transcript\n";
1292 - $transcript_content .= "================\n\n";
1293 - $transcript_content .= "Session ID: " . $session_id . "\n";
1294 -
1295 - if ($session_data) {
1296 - $transcript_content .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1297 - $transcript_content .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1298 - $transcript_content .= "Started: " . $session_data->created_at . "\n";
1299 - }
1300 -
1301 - $transcript_content .= "\n" . str_repeat("=", 50) . "\n\n";
1302 -
1303 - // Add messages
1304 - foreach ($messages as $msg) {
1305 - // 'agent' rows are live-agent (human) replies — label them as such in
1306 - // the emailed transcript, same distinction the Transcripts viewer draws.
1307 - $role_label = ($msg->role === 'user') ? 'User' : (($msg->role === 'agent') ? 'Live Agent' : 'Assistant');
1308 - $transcript_content .= "[{$msg->timestamp}] {$role_label}:\n";
1309 - $transcript_content .= $msg->message . "\n\n";
1310 - }
1311 -
1312 - // Create temporary file for attachment using WP_Filesystem
1313 - $upload_dir = wp_upload_dir();
1314 - // basename() is the SECOND independent control on this write
1315 - // (plan-mxchat-20260731-d42bec). The validator above already rejects any id
1316 - // containing a path separator; this survives someone loosening it later.
1317 - $temp_file = $upload_dir['basedir'] . '/' . basename('mxchat-transcript-' . $session_id . '.txt');
1318 - global $wp_filesystem;
1319 - if (empty($wp_filesystem)) {
1320 - require_once ABSPATH . 'wp-admin/includes/file.php';
1321 - WP_Filesystem();
1322 - }
1323 - $wp_filesystem->put_contents($temp_file, $transcript_content, FS_CHMOD_FILE);
1324 -
1325 - // Prepare email
1326 - $subject = sprintf('[%s] Chat Transcript - Session %s', get_bloginfo('name'), substr($session_id, 0, 8));
1327 -
1328 - $message = "Please find attached the full chat transcript.\n\n";
1329 - $message .= "Session ID: {$session_id}\n";
1330 -
1331 - if ($session_data) {
1332 - $message .= "User: " . ($session_data->user_identifier ?: 'Guest') . "\n";
1333 - $message .= "Email: " . ($session_data->user_email ?: 'Not provided') . "\n";
1334 - }
1335 -
1336 - $message .= "\nView online: " . admin_url('admin.php?page=mxchat-transcripts');
1337 -
1338 - // Send email with attachment
1339 - $attachments = array($temp_file);
1340 - $result = wp_mail($to, $subject, $message, '', $attachments);
1341 -
1342 - // Clean up temporary file
1343 - if (file_exists($temp_file)) {
1344 - unlink($temp_file);
1345 - }
1346 -
1347 - return $result;
1348 -}
1349 -
1350 -
1351 -
1352 546 public function mxchat_handle_save_email_and_response() {
1353 547 //error_log('[DEBUG] ---------- mxchat_handle_save_email_and_response START ----------');
1354 548 //error_log('DEBUG: POST data: ' . print_r($_POST, true));
1355 549
1356 - nocache_headers();
1357 -
1358 550 // Validate nonce
1359 - if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
551 + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
1360 552 //error_log(esc_html__('[ERROR] Invalid nonce in mxchat_handle_save_email_and_response', 'mxchat'));
1361 553 wp_send_json_error(['message' => esc_html__('Invalid nonce.', 'mxchat')]);
1362 554 wp_die();
1363 555 }
1364 556
1365 - $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
557 + $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1366 558 $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
1367 559 $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
1368 560
1369 561 //error_log("[DEBUG] handle_save_email_and_response -> session_id: {$session_id}, email: {$email}, name: {$name}");
1370 562
1371 - if (empty($session_id) || $session_id === 'null' || empty($email)) {
563 + if (empty($session_id) || empty($email)) {
1372 564 //error_log("[ERROR] Missing session_id or email: session_id={$session_id}, email={$email}");
1373 565 wp_send_json_error(['message' => esc_html__('Session ID or email is missing.', 'mxchat')]);
1374 566 wp_die();
1375 567 }
1376 568
1377 - // Validate name if provided (check if name field is enabled and name is required)
569 + // NEW: Validate name if provided (check if name field is enabled and name is required)
1378 570 $options = get_option('mxchat_options', []);
1379 571 $name_field_enabled = isset($options['enable_name_field']) &&
1380 572 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1381 573
@@ -1386,15 +578,15 @@
1386 578 }
1387 579
1388 580 // 1) Always store email in wp_options
1389 581 $email_option_key = "mxchat_email_{$session_id}";
1390 - update_option($email_option_key, $email, 'no');
582 + update_option($email_option_key, $email);
1391 583 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$email_option_key} => {$email}");
1392 584
1393 - // Store name in wp_options if provided
585 + // NEW: Store name in wp_options if provided
1394 586 if (!empty($name)) {
1395 587 $name_option_key = "mxchat_name_{$session_id}";
1396 - update_option($name_option_key, $name, 'no');
588 + update_option($name_option_key, $name);
1397 589 //error_log("[DEBUG] handle_save_email_and_response -> updated option: {$name_option_key} => {$name}");
1398 590 }
1399 591
1400 592 // 2) (Optional) Also store in DB if a row already exists
@@ -1407,9 +599,9 @@
1407 599
1408 600 //error_log("[DEBUG] handle_save_email_and_response -> session_count for {$session_id}: {$session_count} (SQL: {$sql})");
1409 601
1410 602 if ($session_count) {
1411 - // Update both user_email and user_name if row(s) exist
603 + // NEW: Update both user_email and user_name if row(s) exist
1412 604 if (!empty($name)) {
1413 605 $update_sql = $wpdb->prepare(
1414 606 "UPDATE {$table_name} SET user_email = %s, user_name = %s WHERE session_id = %s",
1415 607 $email,
@@ -1438,17 +630,15 @@
1438 630
1439 631 public function mxchat_check_email_provided() {
1440 632 //error_log('[DEBUG] ---------- mxchat_check_email_provided START ----------');
1441 633
1442 - nocache_headers();
1443 -
1444 - if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
634 + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
1445 635 //error_log('[ERROR] Invalid nonce in mxchat_check_email_provided');
1446 636 wp_send_json_error(['message' => esc_html__('Invalid nonce', 'mxchat')]);
1447 637 }
1448 638
1449 - $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
1450 - if (empty($session_id) || $session_id === 'null') {
639 + $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
640 + if (empty($session_id)) {
1451 641 //error_log('[ERROR] No session ID provided in mxchat_check_email_provided');
1452 642 wp_send_json_error(['message' => esc_html__('No session ID provided', 'mxchat')]);
1453 643 }
1454 644
@@ -1456,9 +646,9 @@
1456 646 if (is_user_logged_in()) {
1457 647 $current_user = wp_get_current_user();
1458 648 //error_log("[DEBUG] User is logged in as {$current_user->user_email}");
1459 649
1460 - // Get user's display name for logged in users
650 + // NEW: Get user's display name for logged in users
1461 651 $user_name = !empty($current_user->display_name) ? $current_user->display_name :
1462 652 (!empty($current_user->first_name) ? $current_user->first_name : '');
1463 653
1464 654 $response_data = ['logged_in' => true, 'email' => $current_user->user_email];
@@ -1468,9 +658,9 @@
1468 658
1469 659 wp_send_json_success($response_data);
1470 660 }
1471 661
1472 - // Check if name field is required
662 + // NEW: Check if name field is required
1473 663 $options = get_option('mxchat_options', []);
1474 664 $name_field_enabled = isset($options['enable_name_field']) &&
1475 665 ($options['enable_name_field'] === '1' || $options['enable_name_field'] === 'on');
1476 666
@@ -1476,9 +666,9 @@
1476 666
1477 667 $email_option_key = "mxchat_email_{$session_id}";
1478 668 $stored_email = get_option($email_option_key, '');
1479 669
1480 - // Check for stored name
670 + // NEW: Check for stored name
1481 671 $name_option_key = "mxchat_name_{$session_id}";
1482 672 $stored_name = get_option($name_option_key, '');
1483 673
1484 674 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
@@ -1483,9 +673,9 @@
1483 673
1484 674 //error_log("[DEBUG] mxchat_check_email_provided -> Checking email option: {$email_option_key}, found: {$stored_email}");
1485 675 //error_log("[DEBUG] mxchat_check_email_provided -> Checking name option: {$name_option_key}, found: {$stored_name}, required: " . ($name_field_enabled ? 'yes' : 'no'));
1486 676
1487 - // Check if we have email and name (if name is required)
677 + // NEW: Check if we have email and name (if name is required)
1488 678 $has_required_info = !empty($stored_email);
1489 679
1490 680 if ($name_field_enabled) {
1491 681 $has_required_info = $has_required_info && !empty($stored_name);
@@ -1505,59 +695,32 @@
1505 695 wp_send_json_error(['message' => esc_html__('No email found', 'mxchat')]);
1506 696 }
1507 697 }
1508 698
1509 -/**
1510 - * Send error response in appropriate format based on streaming mode
1511 - * ADDED: Helper method to consistently handle errors in both streaming and non-streaming modes
1512 - *
1513 - * @param string $error_message The error message to display
1514 - * @param string $error_code Optional error code for debugging
1515 - */
1516 -private function send_error_response($error_message, $error_code = 'api_error') {
1517 - if ($this->is_streaming) {
1518 - echo "data: " . json_encode([
1519 - 'error' => true,
1520 - 'error_message' => $error_message,
1521 - 'error_code' => $error_code,
1522 - 'text' => $error_message,
1523 - 'message' => $error_message
1524 - ]) . "\n\n";
1525 - echo "data: [DONE]\n\n";
1526 - flush();
1527 - } else {
1528 - wp_send_json_error([
1529 - 'error_message' => $error_message,
1530 - 'error_code' => $error_code
1531 - ]);
1532 - }
1533 - wp_die();
1534 -}
1535 -
1536 699 public function mxchat_handle_chat_request() {
1537 700 global $wpdb;
1538 701
1539 - // Debug: Log incoming bot_id
1540 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
1541 - //error_log("=== MXCHAT DEBUG: Starting chat request ===");
1542 - //error_log("MXCHAT DEBUG: Bot ID received: " . $bot_id);
702 + // NEW: Check if this is a streaming request
703 + $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat';
1543 704
1544 - // Get bot-specific options
1545 - $bot_options = $this->get_bot_options($bot_id);
1546 - $current_options = !empty($bot_options) ? $bot_options : $this->options;
705 + // NEW: Set streaming headers if needed
706 + if ($is_streaming) {
707 + // Disable output buffering
708 + while (ob_get_level()) {
709 + ob_end_flush(); // Changed from ob_end_clean()
710 + }
711 +
712 + // Set headers for SSE
713 + header('Content-Type: text/event-stream');
714 + header('Cache-Control: no-cache');
715 + header('Connection: keep-alive');
716 + header('X-Accel-Buffering: no');
717 +
718 + // Add these new lines:
719 + ob_implicit_flush(true);
720 + flush();
721 + }
1547 722
1548 - // Check if this is a streaming request
1549 - // Allow force_streaming_test parameter to bypass the setting check (for admin compatibility testing)
1550 - $force_streaming_test = isset($_POST['force_streaming_test']) && $_POST['force_streaming_test'] === '1' && current_user_can('administrator');
1551 - $is_streaming = isset($_POST['action']) && $_POST['action'] === 'mxchat_stream_chat' &&
1552 - ($force_streaming_test || (isset($current_options['enable_streaming_toggle']) && $current_options['enable_streaming_toggle'] === 'on'));
1553 -
1554 - // ADDED: Store streaming state in class property for use in private methods
1555 - $this->is_streaming = $is_streaming;
1556 -
1557 - // NOTE: Streaming headers are now set later via setup_streaming_headers()
1558 - // This allows actions/forms to return JSON responses without header conflicts
1559 -
1560 723 // Check if MX Chat Moderation is active
1561 724 if (class_exists('MX_Chat_Moderation')) {
1562 725 // Get user email and IP
1563 726 $user_email = '';
@@ -1594,13 +757,8 @@
1594 757 }
1595 758
1596 759 $this->fallbackResponse = ['text' => '', 'html' => '', 'images' => []];
1597 760 $this->productCardHtml = '';
1598 - $this->videoEmbedHtml = '';
1599 - // Reset the per-turn function-calling UI capture (plan 48a57a).
1600 - $this->fc_ui_html = '';
1601 - $this->fc_ui_images = array();
1602 - $this->fc_ui_captured = false;
1603 761
1604 762 // Get the actual WordPress user ID if logged in
1605 763 $is_logged_in = is_user_logged_in();
1606 764 if ($is_logged_in) {
@@ -1625,241 +783,223 @@
1625 783 wp_die();
1626 784 }
1627 785
1628 786 // Rest of your existing code...
1629 - $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
787 + $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
1630 788
1631 - // Treat the literal strings 'null' / 'undefined' as missing too. Browser edge cases
1632 - // (Safari ITP, private mode, cross-origin iframes with partitioned storage) can cause
1633 - // the frontend FormData.append() to stringify a null session_id into the literal
1634 - // "null", which would otherwise pass empty() and pollute the transcripts table with
1635 - // ghost sessions that group every visitor's first message under one row.
1636 - if ($session_id === 'null' || $session_id === 'undefined') {
1637 - $session_id = '';
1638 - }
1639 -
1640 789 if (empty($session_id)) {
1641 790 wp_send_json_error(esc_html__('Session ID is missing.', 'mxchat'));
1642 791 wp_die();
1643 792 }
1644 793
1645 - // Update session owner if it changed (e.g. IP changed due to network switch)
1646 - // The session ID itself is the authentication — if the client has it, they own it
1647 - $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
1648 - $session_owner = get_option("mxchat_session_owner_{$session_id}");
1649 -
1650 - if (!$session_owner || $session_owner !== $current_user_identifier) {
1651 - update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
1652 - }
1653 -
1654 794 // Validate and sanitize the incoming message
1655 795 if (empty($_POST['message'])) {
1656 796 wp_send_json_error(esc_html__('No message received.', 'mxchat'));
1657 797 wp_die();
1658 798 }
799 +
800 +
801 + // NEW: Track originating page for first message in session
802 +$table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1659 803
1660 - // Enforce the configurable max input length (plan a3fae2 part C). 0 = unlimited.
1661 - // Server-side guard backing the textarea's client-side maxlength (which is bypassable).
1662 - // Reads the global core setting and measures characters (mb_strlen on the unslashed
1663 - // raw POST), matching the maxlength semantics.
1664 - $mxchat_max_input_length = isset($this->options['max_input_length']) ? intval($this->options['max_input_length']) : 0;
1665 - if ($mxchat_max_input_length > 0) {
1666 - $mxchat_incoming_raw = is_string($_POST['message']) ? wp_unslash($_POST['message']) : '';
1667 - if (mb_strlen($mxchat_incoming_raw) > $mxchat_max_input_length) {
1668 - wp_send_json([
1669 - 'success' => false,
1670 - /* translators: %d: maximum allowed characters */
1671 - 'message' => sprintf(esc_html__('Your message is too long. Please keep it under %d characters.', 'mxchat'), $mxchat_max_input_length),
1672 - 'status' => 'message_too_long'
1673 - ]);
1674 - wp_die();
804 +// Check if originating page columns exist
805 +$columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
806 +
807 +if ($columns_exist) {
808 + // Check if this session already has messages
809 + $message_count = $wpdb->get_var($wpdb->prepare(
810 + "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
811 + $session_id
812 + ));
813 +
814 + // If this is the first message in the session
815 + if ($message_count == 0) {
816 + // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
817 + $originating_url = '';
818 + $originating_title = '';
819 +
820 + // Try to get from POST data first (sent by JavaScript)
821 + if (isset($_POST['current_page_url'])) {
822 + $originating_url = esc_url_raw($_POST['current_page_url']);
823 + $originating_title = isset($_POST['current_page_title'])
824 + ? sanitize_text_field($_POST['current_page_title'])
825 + : '';
1675 826 }
1676 - }
1677 -
1678 -
1679 - // Track originating page for first message in session
1680 - $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
1681 -
1682 - // Check if originating page columns exist
1683 - $columns_exist = $wpdb->get_var("SHOW COLUMNS FROM $table_name LIKE 'originating_page_url'");
1684 -
1685 - if ($columns_exist) {
1686 - // Check if this session already has messages
1687 - $message_count = $wpdb->get_var($wpdb->prepare(
1688 - "SELECT COUNT(*) FROM $table_name WHERE session_id = %s",
1689 - $session_id
1690 - ));
827 + // Fallback to HTTP_REFERER if not provided by JavaScript
828 + else if (isset($_SERVER['HTTP_REFERER'])) {
829 + $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
830 + }
1691 831
1692 - // If this is the first message in the session
1693 - if ($message_count == 0) {
1694 - // Get originating page from JavaScript (preferred) or HTTP_REFERER (fallback)
1695 - $originating_url = '';
1696 - $originating_title = '';
832 + // Generate title if we have URL but no title
833 + if ($originating_url && empty($originating_title)) {
834 + $parsed_url = parse_url($originating_url);
835 + $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1697 836
1698 - // Try to get from POST data first (sent by JavaScript)
1699 - if (isset($_POST['current_page_url'])) {
1700 - $originating_url = esc_url_raw($_POST['current_page_url']);
1701 - $originating_title = isset($_POST['current_page_title'])
1702 - ? sanitize_text_field($_POST['current_page_title'])
1703 - : '';
837 + if (empty($path) || $path === 'index.php' || $path === 'index.html') {
838 + $originating_title = 'Homepage';
839 + } else {
840 + // Clean up the path to make a readable title
841 + $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
842 + $originating_title = ucwords(trim($originating_title));
1704 843 }
1705 - // Fallback to HTTP_REFERER if not provided by JavaScript
1706 - else if (isset($_SERVER['HTTP_REFERER'])) {
1707 - $originating_url = esc_url_raw($_SERVER['HTTP_REFERER']);
1708 - }
1709 -
1710 - // Generate title if we have URL but no title
1711 - if ($originating_url && empty($originating_title)) {
1712 - $parsed_url = parse_url($originating_url);
1713 - $path = isset($parsed_url['path']) ? trim($parsed_url['path'], '/') : '';
1714 -
1715 - if (empty($path) || $path === 'index.php' || $path === 'index.html') {
1716 - $originating_title = 'Homepage';
1717 - } else {
1718 - // Clean up the path to make a readable title
1719 - $originating_title = str_replace(['-', '_', '/', '.php', '.html'], ' ', $path);
1720 - $originating_title = ucwords(trim($originating_title));
1721 - }
1722 - }
1723 -
1724 - // Store for later use when saving the message
1725 - $this->pending_originating_page = [
1726 - 'url' => $originating_url,
1727 - 'title' => $originating_title
1728 - ];
1729 844 }
845 +
846 + // Store for later use when saving the message
847 + $this->pending_originating_page = [
848 + 'url' => $originating_url,
849 + 'title' => $originating_title
850 + ];
1730 851 }
852 +}
853 +
854 +
855 +
856 + // NEW: Get page context if provided
857 + $page_context = null;
858 + if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
859 + $page_context_raw = stripslashes($_POST['page_context']);
860 + $page_context = json_decode($page_context_raw, true);
1731 861
1732 -
1733 -
1734 - // Get page context if provided
1735 - $page_context = null;
1736 - if (isset($_POST['page_context']) && !empty($_POST['page_context'])) {
1737 - $page_context_raw = stripslashes($_POST['page_context']);
1738 - $page_context = json_decode($page_context_raw, true);
862 + // Validate page context structure
863 + if (is_array($page_context) &&
864 + isset($page_context['url']) &&
865 + isset($page_context['title']) &&
866 + isset($page_context['content'])) {
1739 867
1740 - // Validate page context structure
1741 - if (is_array($page_context) &&
1742 - isset($page_context['url']) &&
1743 - isset($page_context['title']) &&
1744 - isset($page_context['content'])) {
1745 -
1746 - // Sanitize page context
1747 - $page_context['url'] = esc_url_raw($page_context['url']);
1748 - $page_context['title'] = sanitize_text_field($page_context['title']);
1749 - $page_context['content'] = wp_kses_post($page_context['content']);
1750 - } else {
1751 - $page_context = null;
1752 - }
868 + // Sanitize page context
869 + $page_context['url'] = esc_url_raw($page_context['url']);
870 + $page_context['title'] = sanitize_text_field($page_context['title']);
871 + $page_context['content'] = wp_kses_post($page_context['content']);
872 + } else {
873 + $page_context = null;
1753 874 }
875 + }
1754 876
1755 - // Modify the message sanitization to preserve PHP tags in code blocks
1756 - $allowed_tags = [
1757 - 'pre' => [],
1758 - 'code' => ['class' => true],
1759 - 'span' => ['class' => true],
1760 - 'div' => ['class' => true],
1761 - ];
877 + // Modify the message sanitization to preserve PHP tags in code blocks
878 + $allowed_tags = [
879 + 'pre' => [],
880 + 'code' => ['class' => true],
881 + 'span' => ['class' => true],
882 + 'div' => ['class' => true],
883 + ];
1762 884
1763 - // First preserve code blocks
1764 - $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
1765 - return htmlspecialchars_decode($matches[0]);
1766 - }, $_POST['message']);
885 + // First preserve code blocks
886 + $message = preg_replace_callback('/<pre><code.*?>.*?<\/code><\/pre>/s', function($matches) {
887 + return htmlspecialchars_decode($matches[0]);
888 + }, $_POST['message']);
1767 889
1768 - // Then apply sanitization
1769 - $message = wp_kses($message, $allowed_tags);
890 + // Then apply sanitization
891 + $message = wp_kses($message, $allowed_tags);
1770 892
1771 - // Preserve code blocks from markdown conversion
1772 - $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
1773 - $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
893 + // Preserve code blocks from markdown conversion
894 + $message = preg_replace('/```(\w+)?\s*([\s\S]+?)```/s', '<pre><code class="$1">$2</code></pre>', $message);
895 + $message = apply_filters('mxchat_filter_message', $message, 'prompt', $session_id);
1774 896
1775 - // ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
1776 - // Always initialize testing data for admins (no toggle needed)
1777 - $testing_data = null;
1778 - if (current_user_can('administrator')) {
1779 - // For vision messages, use the original user message for the query display
1780 - $query_for_testing = $message;
1781 - if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1782 - $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1783 - }
1784 -
1785 - $testing_data = [
1786 - 'query' => $query_for_testing,
1787 - 'timestamp' => time(),
1788 - 'top_matches' => [],
1789 - 'action_matches' => [], // Initialize action matches array
1790 - 'page_context' => $page_context, // Include page context in testing data
1791 - 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed'],
1792 - 'bot_id' => $bot_id // Include bot ID in testing data
1793 - ];
1794 -
1795 - // Get similarity threshold from bot options or default options
1796 - $similarity_threshold = isset($current_options['similarity_threshold'])
1797 - ? ((int) $current_options['similarity_threshold']) / 100
1798 - : 0.35;
1799 -
1800 - $testing_data['similarity_threshold'] = $similarity_threshold;
1801 -
1802 - // Determine knowledge base type using bot-specific config
1803 - $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
1804 - $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
1805 - $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
897 +// ===== SIMPLIFIED TESTING PANEL INITIALIZATION =====
898 + // Always initialize testing data for admins (no toggle needed)
899 + $testing_data = null;
900 + if (current_user_can('administrator')) {
901 + // For vision messages, use the original user message for the query display
902 + $query_for_testing = $message;
903 + if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
904 + $query_for_testing = sanitize_textarea_field($_POST['original_user_message']);
1806 905 }
1807 - // ===== END SIMPLIFIED TESTING INITIALIZATION =====
906 +
907 + $testing_data = [
908 + 'query' => $query_for_testing,
909 + 'timestamp' => time(),
910 + 'top_matches' => [],
911 + 'action_matches' => [], // NEW: Initialize action matches array
912 + 'page_context' => $page_context, // NEW: Include page context in testing data
913 + 'is_vision' => isset($_POST['vision_processed']) && $_POST['vision_processed']
914 + ];
915 +
916 + // Get similarity threshold
917 + $similarity_threshold = isset($this->options['similarity_threshold'])
918 + ? ((int) $this->options['similarity_threshold']) / 100
919 + : 0.75;
920 +
921 + $testing_data['similarity_threshold'] = $similarity_threshold;
922 +
923 + // Determine knowledge base type
924 + $addon_options = get_option('mxchat_pinecone_addon_options', array());
925 + $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
926 + $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
927 + }
928 + // ===== END SIMPLIFIED TESTING INITIALIZATION =====
1808 929
1809 - // Add debug before and after:
1810 - //error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
1811 - $pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
1812 - //error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
930 +// Add debug before and after:
931 +//error_log('MxChat Core: About to call mxchat_pre_process_message filter with message: ' . $message);
932 +$pre_processed_result = apply_filters('mxchat_pre_process_message', $message, $user_id, $session_id);
933 +//error_log('MxChat Core: Filter returned: ' . (is_array($pre_processed_result) ? 'array' : $pre_processed_result));
1813 934
1814 935
1815 - // If the pre-processing returned a result (not the original message), use it directly
1816 - if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
1817 - // Save the AI response
1818 - $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
1819 -
1820 - // Save HTML content if provided
1821 - if (!empty($pre_processed_result['html'])) {
1822 - $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1823 - }
1824 -
1825 - // Add testing data if admin
1826 - $response_data = [
1827 - 'text' => $pre_processed_result['text'],
1828 - 'html' => $pre_processed_result['html'] ?? '',
1829 - 'session_id' => $session_id
1830 - ];
1831 -
1832 - if ($testing_data !== null) {
1833 - $response_data['testing_data'] = $testing_data;
1834 - }
1835 -
1836 - wp_send_json($response_data);
1837 - wp_die();
936 + // If the pre-processing returned a result (not the original message), use it directly
937 + if (is_array($pre_processed_result) && isset($pre_processed_result['text'])) {
938 + // Save the AI response
939 + $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['text']);
940 +
941 + // Save HTML content if provided
942 + if (!empty($pre_processed_result['html'])) {
943 + $this->mxchat_save_chat_message($session_id, 'bot', $pre_processed_result['html']);
1838 944 }
945 +
946 + // Add testing data if admin
947 + $response_data = [
948 + 'text' => $pre_processed_result['text'],
949 + 'html' => $pre_processed_result['html'] ?? '',
950 + 'session_id' => $session_id
951 + ];
952 +
953 + if ($testing_data !== null) {
954 + $response_data['testing_data'] = $testing_data;
955 + }
956 +
957 + wp_send_json($response_data);
958 + wp_die();
959 + }
1839 960
1840 - // Save the user's message - handle vision processed messages differently
1841 - if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
1842 - // For vision messages, save the original user message with image indicator
1843 - $original_message = sanitize_textarea_field($_POST['original_user_message']);
1844 - if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
1845 - $image_count = intval($_POST['vision_images_count']);
1846 - $original_message .= " [{$image_count} image(s)]";
1847 - }
1848 - $this->mxchat_save_chat_message($session_id, 'user', $original_message);
1849 - } else {
1850 - // Regular message - save as normal
1851 - $this->mxchat_save_chat_message($session_id, 'user', $message);
961 + // Save the user's message - handle vision processed messages differently
962 + if (isset($_POST['vision_processed']) && $_POST['vision_processed'] && isset($_POST['original_user_message'])) {
963 + // For vision messages, save the original user message with image indicator
964 + $original_message = sanitize_textarea_field($_POST['original_user_message']);
965 + if (isset($_POST['vision_images_count']) && $_POST['vision_images_count'] > 0) {
966 + $image_count = intval($_POST['vision_images_count']);
967 + $original_message .= " [{$image_count} image(s)]";
1852 968 }
969 + $this->mxchat_save_chat_message($session_id, 'user', $original_message);
970 + } else {
971 + // Regular message - save as normal
972 + $this->mxchat_save_chat_message($session_id, 'user', $message);
973 + }
1853 974
975 +
976 +if (is_email($message)) {
977 + // Add the email to Loops
978 + $this->add_email_to_loops($message);
1854 979
1855 - if (is_email($message)) {
1856 - // Add the email to Loops
1857 - $this->add_email_to_loops($message);
980 + // Get the user's success message instruction
981 + $user_success_message = $this->options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
982 +
983 + // Set instruction for AI using the user's success message
984 + $this->current_action_instruction = $user_success_message;
985 +
986 + // Clear the email capture transient since we got the email
987 + delete_transient('mxchat_email_capture_' . $user_id);
988 + }
989 +
990 + // NEW: Check if we're in an email capture flow but user hasn't provided email yet
991 + elseif (get_transient('mxchat_email_capture_' . $user_id)) {
992 + // Check if the message contains an email (not the whole message being an email)
993 + if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
994 + $extracted_email = $matches[0];
1858 995
1859 - // Get the user's success message instruction using current_options
1860 - $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
996 + // Add the extracted email to Loops
997 + $this->add_email_to_loops($extracted_email);
1861 998
999 + // Get the user's success message instruction
1000 + $user_success_message = $this->options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1001 +
1862 1002 // Set instruction for AI using the user's success message
1863 1003 $this->current_action_instruction = $user_success_message;
1864 1004
1865 1005 // Clear the email capture transient since we got the email
@@ -1864,820 +1004,468 @@
1864 1004
1865 1005 // Clear the email capture transient since we got the email
1866 1006 delete_transient('mxchat_email_capture_' . $user_id);
1867 1007 }
1868 -
1869 - // Check if we're in an email capture flow but user hasn't provided email yet
1870 - elseif (get_transient('mxchat_email_capture_' . $user_id)) {
1871 - // Check if the message contains an email (not the whole message being an email)
1872 - if (preg_match('/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/', $message, $matches)) {
1873 - $extracted_email = $matches[0];
1874 -
1875 - // Add the extracted email to Loops
1876 - $this->add_email_to_loops($extracted_email);
1877 -
1878 - // Get the user's success message instruction using current_options
1879 - $user_success_message = $current_options['email_capture_response'] ?? __('Thank you for providing your email! You\'ve been added to our list.', 'mxchat');
1880 -
1881 - // Set instruction for AI using the user's success message
1882 - $this->current_action_instruction = $user_success_message;
1883 -
1884 - // Clear the email capture transient since we got the email
1885 - delete_transient('mxchat_email_capture_' . $user_id);
1886 - }
1887 - // If no email found but we're in capture mode, remind them
1888 - else {
1889 - // Get the original instruction to remind them using current_options
1890 - $original_instruction = $current_options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1891 - $this->current_action_instruction = $original_instruction;
1892 - }
1008 + // If no email found but we're in capture mode, remind them
1009 + else {
1010 + // Get the original instruction to remind them
1011 + $original_instruction = $this->options['triggered_phrase_response'] ?? __("Please provide your email address.", 'mxchat');
1012 + $this->current_action_instruction = $original_instruction;
1893 1013 }
1014 + }
1894 1015
1895 - $intent_info = '';
1016 + $intent_info = '';
1896 1017
1897 - // Check chat mode
1898 - $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1018 + // Check chat mode
1019 + $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
1899 1020
1900 - // Handle agent mode
1901 1021 // Handle agent mode
1902 - if ($chat_mode === 'agent') {
1903 - // First, check for switch intent before doing anything else
1904 - $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1022 +// Handle agent mode
1023 + if ($chat_mode === 'agent') {
1024 + // First, check for switch intent before doing anything else
1025 + $intent_matched = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
1905 1026
1906 - // Capture action analysis for testing panel after intent check
1907 - if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1908 - $testing_data['action_matches'] = $this->last_action_analysis;
1027 + // NEW: Capture action analysis for testing panel after intent check
1028 + if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1029 + $testing_data['action_matches'] = $this->last_action_analysis;
1030 + }
1031 +
1032 + // Around line 506, in the agent mode handling section:
1033 + if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1034 + // Update chat mode first
1035 + update_option("mxchat_mode_{$session_id}", 'ai');
1036 +
1037 + // Clear any existing PDF context to start fresh
1038 + $this->clear_pdf_transients($session_id);
1039 +
1040 + // Prepare clean switch response with explicit chat_mode
1041 + $response_data = [
1042 + 'text' => $this->fallbackResponse['text'],
1043 + 'html' => $this->fallbackResponse['html'] ?? '',
1044 + 'session_id' => $session_id,
1045 + 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1046 + ];
1047 +
1048 + if ($testing_data !== null) {
1049 + $response_data['testing_data'] = $testing_data;
1909 1050 }
1910 -
1911 - // Around line 506, in the agent mode handling section:
1912 - if ($intent_matched && !empty($this->fallbackResponse['text'])) {
1913 - // Update chat mode first
1914 - update_option("mxchat_mode_{$session_id}", 'ai');
1915 -
1916 - // Clear any existing PDF context to start fresh
1917 - $this->clear_pdf_transients($session_id);
1918 -
1919 - // Prepare clean switch response with explicit chat_mode
1920 - $response_data = [
1921 - 'text' => $this->fallbackResponse['text'],
1922 - 'html' => $this->fallbackResponse['html'] ?? '',
1923 - 'session_id' => $session_id,
1924 - 'chat_mode' => 'ai' // EXPLICITLY SET THIS
1051 +
1052 + // Save the mode switch message
1053 + $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1054 + $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1055 +
1056 + // Send response and exit
1057 + wp_send_json($response_data);
1058 + wp_die();
1059 + } elseif (!$intent_matched) {
1060 + // No intent matched, handle live agent message
1061 + try {
1062 + $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1063 +
1064 + $agent_response = [
1065 + 'status' => 'waiting_for_agent',
1066 + 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1925 1067 ];
1926 -
1068 +
1927 1069 if ($testing_data !== null) {
1928 - $response_data['testing_data'] = $testing_data;
1070 + $agent_response['testing_data'] = $testing_data;
1929 1071 }
1930 -
1931 - // Save the mode switch message
1932 - $this->mxchat_save_chat_message($session_id, 'system', esc_html__('Switched to AI chat mode', 'mxchat'));
1933 - $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
1934 -
1935 - // Send response and exit
1936 - wp_send_json($response_data);
1937 - wp_die();
1938 - } elseif (!$intent_matched) {
1939 - // No intent matched, handle live agent message
1940 - try {
1941 - $this->mxchat_send_user_message_to_agent($message, $user_id, $session_id);
1942 1072
1943 - $agent_response = [
1944 - 'status' => 'waiting_for_agent',
1945 - 'message' => esc_html__('Message sent to live agent.', 'mxchat')
1946 - ];
1947 -
1948 - if ($testing_data !== null) {
1949 - $agent_response['testing_data'] = $testing_data;
1950 - }
1951 -
1952 - wp_send_json_success($agent_response);
1953 - } catch (\Exception $e) {
1954 - wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1955 - }
1956 - wp_die();
1073 + wp_send_json_success($agent_response);
1074 + } catch (\Exception $e) {
1075 + wp_send_json_error(esc_html__('Failed to send message to agent', 'mxchat'));
1957 1076 }
1077 + wp_die();
1958 1078 }
1079 + }
1959 1080
1960 - // Step 1: Check for new PDF URL in the message
1961 - if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1962 - $new_pdf_url = $matches[0];
1081 + // Step 1: Check for new PDF URL in the message
1082 + if (!isset($_POST['vision_processed']) && preg_match('/https?:\/\/[^\s"]+/i', $message, $matches)) {
1083 + $new_pdf_url = $matches[0];
1963 1084
1964 - // Check if this is likely a PDF-related request
1965 - $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1966 - $is_pdf_request = false;
1085 + // Check if this is likely a PDF-related request
1086 + $pdf_keywords = ['pdf', 'document', 'read', 'analyze'];
1087 + $is_pdf_request = false;
1967 1088
1968 - foreach ($pdf_keywords as $keyword) {
1969 - if (stripos($message, $keyword) !== false) {
1970 - $is_pdf_request = true;
1971 - break;
1972 - }
1089 + foreach ($pdf_keywords as $keyword) {
1090 + if (stripos($message, $keyword) !== false) {
1091 + $is_pdf_request = true;
1092 + break;
1973 1093 }
1094 + }
1974 1095
1975 - // If it looks like a PDF request or we're waiting for a PDF URL
1976 - if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1977 - // Validate HTTPS
1978 - if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1979 - // Extract filename from URL
1980 - $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1096 + // If it looks like a PDF request or we're waiting for a PDF URL
1097 + if ($is_pdf_request || get_transient('mxchat_waiting_for_pdf_url_' . $session_id)) {
1098 + // Validate HTTPS
1099 + if (wp_http_validate_url($new_pdf_url) && parse_url($new_pdf_url, PHP_URL_SCHEME) === 'https') {
1100 + // Extract filename from URL
1101 + $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1981 1102
1982 - // Clear previous PDF transients
1983 - $this->clear_pdf_transients($session_id);
1103 + // Clear previous PDF transients
1104 + $this->clear_pdf_transients($session_id);
1984 1105
1985 - // Process new PDF using current_options
1986 - $max_pages = $current_options['pdf_max_pages'] ?? 69;
1987 - $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1106 + // Process new PDF
1107 + $max_pages = $this->options['pdf_max_pages'] ?? 69;
1108 + $embeddings = $this->fetch_and_split_pdf_pages($new_pdf_url, $max_pages);
1988 1109
1989 - if ($embeddings === 'too_many_pages') {
1990 - $error_text = sprintf(
1991 - $current_options['pdf_intent_error_text'] ??
1992 - esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1993 - $max_pages
1994 - );
1995 - $this->fallbackResponse['text'] = $error_text;
1996 - } elseif ($embeddings) {
1997 - // Store new PDF information
1998 - $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1110 + if ($embeddings === 'too_many_pages') {
1111 + $error_text = sprintf(
1112 + $this->options['pdf_intent_error_text'] ??
1113 + esc_html__("The provided PDF exceeds the maximum allowed limit of %d pages. Please provide a smaller document.", 'mxchat'),
1114 + $max_pages
1115 + );
1116 + $this->fallbackResponse['text'] = $error_text;
1117 + } elseif ($embeddings) {
1118 + // Store new PDF information
1119 + $pdf_filename = basename(parse_url($new_pdf_url, PHP_URL_PATH));
1999 1120
2000 - // If the filename is generic, create a more descriptive one
2001 - if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
2002 - strpos($pdf_filename, '.php') !== false) {
2003 - $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
2004 - }
1121 + // If the filename is generic, create a more descriptive one
1122 + if (in_array($pdf_filename, ['results_download.php', 'download.php', 'view.php', 'pdf.php']) ||
1123 + strpos($pdf_filename, '.php') !== false) {
1124 + $pdf_filename = 'Document_' . date('Y-m-d_H-i') . '.pdf';
1125 + }
2005 1126
2006 - set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
2007 - set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
2008 - set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
2009 - set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
1127 + set_transient('mxchat_pdf_url_' . $session_id, $new_pdf_url, HOUR_IN_SECONDS);
1128 + set_transient('mxchat_pdf_filename_' . $session_id, $pdf_filename, HOUR_IN_SECONDS);
1129 + set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
1130 + set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
2010 1131
2011 - $success_text = $current_options['pdf_intent_success_text'] ??
2012 - esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
1132 + $success_text = $this->options['pdf_intent_success_text'] ??
1133 + esc_html__("I've processed the new PDF '{$pdf_filename}'. What questions do you have about it?", 'mxchat');
2013 1134
2014 - $pdf_response = [
2015 - 'success' => true,
2016 - 'message' => $success_text,
2017 - 'data' => [
2018 - 'filename' => $pdf_filename
2019 - ]
2020 - ];
2021 -
2022 - if ($testing_data !== null) {
2023 - $pdf_response['testing_data'] = $testing_data;
2024 - }
2025 -
2026 - wp_send_json($pdf_response);
2027 - wp_die();
2028 - } else {
2029 - $error_text = $current_options['pdf_intent_error_text'] ??
2030 - esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
2031 - $this->fallbackResponse['text'] = $error_text;
2032 - }
2033 -
2034 - $pdf_error_response = [
2035 - 'success' => false,
2036 - 'message' => $this->fallbackResponse['text']
1135 + $pdf_response = [
1136 + 'success' => true,
1137 + 'message' => $success_text,
1138 + 'data' => [
1139 + 'filename' => $pdf_filename
1140 + ]
2037 1141 ];
2038 1142
2039 1143 if ($testing_data !== null) {
2040 - $pdf_error_response['testing_data'] = $testing_data;
1144 + $pdf_response['testing_data'] = $testing_data;
2041 1145 }
2042 1146
2043 - wp_send_json($pdf_error_response);
1147 + wp_send_json($pdf_response);
2044 1148 wp_die();
1149 + } else {
1150 + $error_text = $this->options['pdf_intent_error_text'] ??
1151 + esc_html__("Sorry, I couldn't process the PDF. Please ensure it's a valid file.", 'mxchat');
1152 + $this->fallbackResponse['text'] = $error_text;
2045 1153 }
2046 - }
2047 - }
2048 1154
2049 -
2050 - // Step 2: Detect intent and handle intent-based responses
2051 - $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
2052 -
2053 - // Capture action analysis for testing panel after intent check
2054 - if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
2055 - $testing_data['action_matches'] = $this->last_action_analysis;
2056 - }
2057 -
2058 - // Step 3: Handle the intent result appropriately
2059 - if ($intent_result !== false) {
2060 - // Intent was matched - ALWAYS send as JSON response, never streaming
2061 -
2062 - if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
2063 - // Intent returned a direct response array
2064 - $response_data = [
2065 - 'text' => $intent_result['text'] ?? '',
2066 - 'html' => $intent_result['html'] ?? '',
2067 - 'session_id' => $session_id
1155 + $pdf_error_response = [
1156 + 'success' => false,
1157 + 'message' => $this->fallbackResponse['text']
2068 1158 ];
2069 -
2070 - // IMPORTANT: Include chat_mode if present (for WhatsApp, Slack, etc.)
2071 - if (isset($intent_result['chat_mode'])) {
2072 - $response_data['chat_mode'] = $intent_result['chat_mode'];
2073 - }
2074 -
1159 +
2075 1160 if ($testing_data !== null) {
2076 - $response_data['testing_data'] = $testing_data;
1161 + $pdf_error_response['testing_data'] = $testing_data;
2077 1162 }
2078 1163
2079 - wp_send_json($response_data);
1164 + wp_send_json($pdf_error_response);
2080 1165 wp_die();
2081 - } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
2082 - // Intent returned true and set fallbackResponse
2083 -
2084 - // SAVE TO TRANSCRIPT
2085 - if (!empty($this->fallbackResponse['text'])) {
2086 - $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
2087 - }
2088 - // Save action HTML (product cards, featured products, etc.) so it renders in transcripts
2089 - if (!empty($this->fallbackResponse['html'])) {
2090 - $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2091 - }
2092 -
2093 - $response_data = [
2094 - 'text' => $this->fallbackResponse['text'] ?? '',
2095 - 'html' => $this->fallbackResponse['html'] ?? '',
2096 - 'session_id' => $session_id
2097 - ];
2098 -
2099 - if (isset($this->fallbackResponse['chat_mode'])) {
2100 - $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
2101 - }
2102 -
2103 - if ($testing_data !== null) {
2104 - $response_data['testing_data'] = $testing_data;
2105 - }
2106 -
2107 - wp_send_json($response_data);
2108 - wp_die();
2109 1166 }
2110 1167 }
1168 + }
2111 1169
2112 - // If we get here, no intent matched OR the intent didn't provide a usable response
2113 -
2114 - // Step 4: Generate AI response
2115 - // Get session start timestamp - when persistence is OFF, only include messages from this page load
2116 - $session_start_timestamp = isset($_POST['session_start_timestamp']) ? intval($_POST['session_start_timestamp']) : 0;
2117 - $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id, $session_start_timestamp);
2118 - $this->mxchat_increment_chat_count();
1170 + // Check if there's an active recommendation flow session
1171 + $flow_state = get_option("mxchat_sr_flow_state_{$session_id}", array());
1172 + if (!empty($flow_state) && isset($flow_state['flow_id'])) {
1173 + // Create a dummy intent object that matches the original intent
1174 + $dummy_intent = new stdClass();
1175 + $dummy_intent->intent_label = 'Recommendation Flow ' . $flow_state['flow_id'];
1176 + $dummy_intent->phrases = ''; // Empty phrases to avoid matching the original trigger
2119 1177
2120 - // Generate embedding for the user's query - USE BOT-SPECIFIC API KEY
2121 - $api_key = $current_options['api_key'] ?? $this->options['api_key'];
2122 - $user_message_embedding = $this->mxchat_generate_embedding($message, $api_key);
1178 + // Call the recommendation flow handler directly
1179 + $response_data = apply_filters('mxchat_sr_recommendation_flow', false, $message, $user_id, $session_id, $dummy_intent);
2123 1180
2124 - // Check if the embedding generation returned an error
2125 - if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
2126 - $error_message = $user_message_embedding['error'];
2127 - $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
2128 -
2129 - // FIXED: Send error in appropriate format based on streaming mode
2130 - if ($is_streaming) {
2131 - echo "data: " . json_encode([
2132 - 'error' => true,
2133 - 'error_message' => $error_message,
2134 - 'error_code' => $error_code,
2135 - 'text' => $error_message,
2136 - 'message' => $error_message
2137 - ]) . "\n\n";
2138 - echo "data: [DONE]\n\n";
2139 - flush();
2140 - } else {
2141 - wp_send_json_error([
2142 - 'error_message' => $error_message,
2143 - 'error_code' => $error_code
2144 - ]);
1181 + // If the handler returned a response, send it
1182 + if (is_array($response_data) && (isset($response_data['text']) || isset($response_data['html']))) {
1183 + // Save the bot's response to the chat history
1184 + if (!empty($response_data['text'])) {
1185 + $this->mxchat_save_chat_message($session_id, 'bot', $response_data['text']);
2145 1186 }
1187 + if (!empty($response_data['html'])) {
1188 + $this->mxchat_save_chat_message($session_id, 'bot', $response_data['html']);
1189 + }
1190 +
1191 + if ($testing_data !== null) {
1192 + $response_data['testing_data'] = $testing_data;
1193 + }
1194 +
1195 + // Send the response
1196 + wp_send_json($response_data);
2146 1197 wp_die();
2147 1198 }
1199 + }
2148 1200
2149 - // Check if the embedding is valid
2150 - if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
2151 - $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
1201 + // Step 2: Detect intent and handle intent-based responses
1202 + $intent_result = $this->mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id);
2152 1203
2153 - // FIXED: Send error in appropriate format based on streaming mode
1204 + // NEW: Capture action analysis for testing panel after intent check
1205 + if ($testing_data !== null && isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
1206 + $testing_data['action_matches'] = $this->last_action_analysis;
1207 + }
1208 +
1209 + // Step 3: Handle the intent result appropriately
1210 + if ($intent_result !== false) {
1211 + // Intent was matched - ALWAYS send as JSON response, never streaming
1212 +
1213 + if (is_array($intent_result) && (isset($intent_result['text']) || isset($intent_result['html']))) {
1214 + // Intent returned a direct response array
1215 + $response_data = [
1216 + 'text' => $intent_result['text'] ?? '',
1217 + 'html' => $intent_result['html'] ?? '',
1218 + 'session_id' => $session_id
1219 + ];
1220 +
1221 + if ($testing_data !== null) {
1222 + $response_data['testing_data'] = $testing_data;
1223 + }
1224 +
1225 + // Clear streaming headers if they were set
2154 1226 if ($is_streaming) {
2155 - echo "data: " . json_encode([
2156 - 'error' => true,
2157 - 'error_message' => $error_message,
2158 - 'error_code' => 'invalid_embedding',
2159 - 'text' => $error_message,
2160 - 'message' => $error_message
2161 - ]) . "\n\n";
2162 - echo "data: [DONE]\n\n";
2163 - flush();
2164 - } else {
2165 - wp_send_json_error([
2166 - 'error_message' => $error_message,
2167 - 'error_code' => 'invalid_embedding'
2168 - ]);
1227 + header_remove('Content-Type');
1228 + header_remove('Cache-Control');
1229 + header_remove('Connection');
1230 + header_remove('X-Accel-Buffering');
1231 + header('Content-Type: application/json');
2169 1232 }
1233 +
1234 + wp_send_json($response_data);
2170 1235 wp_die();
2171 - }
2172 -
2173 - // Build context with both knowledge base and PDF content if available
2174 - $context_content = "User asked: '{$message}'\n\n";
2175 -
2176 - // Add action instruction if present (add this right after the above line)
2177 - if (!empty($this->current_action_instruction)) {
2178 - $context_content .= "===== SPECIAL INSTRUCTION =====\n";
2179 - $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
2180 - $context_content .= "Respond naturally and conversationally while following this instruction.\n";
2181 - $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1236 + } else if ($intent_result === true && (!empty($this->fallbackResponse['text']) || !empty($this->fallbackResponse['html']))) {
1237 + // Intent returned true and set fallbackResponse
2182 1238
2183 - // Clear the instruction after using it
2184 - $this->current_action_instruction = null;
2185 - }
2186 -
2187 -
2188 - // Add page context if available and contextual awareness is enabled using current_options
2189 - if ($page_context && isset($current_options['contextual_awareness_toggle']) && $current_options['contextual_awareness_toggle'] === 'on') {
2190 - $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
2191 - $context_content .= "Page URL: " . $page_context['url'] . "\n";
2192 - $context_content .= "Page Title: " . $page_context['title'] . "\n";
2193 - $context_content .= "Page Content: " . $page_context['content'] . "\n";
2194 - $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
2195 - }
2196 -
2197 - // Get relevant content from knowledge base - PASS BOT_ID and MESSAGE for Vector Store
2198 - $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding, $bot_id, $message);
2199 -
2200 - // NEW: Also extract URLs from system instructions (only if citation links enabled)
2201 - // Use fresh options to ensure we get the latest setting value
2202 - $fresh_options = get_option('mxchat_options', []);
2203 - $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
2204 -
2205 - $system_instructions = $this->get_system_instructions($bot_id, $session_id);
2206 - if ($citation_links_enabled && !empty($system_instructions)) {
2207 - preg_match_all(
2208 - '#\bhttps?://[^\s<>"\']+#i',
2209 - $system_instructions,
2210 - $system_instruction_urls
2211 - );
2212 -
2213 - if (!empty($system_instruction_urls[0])) {
2214 - // Merge with existing valid URLs
2215 - $this->current_valid_urls = array_merge(
2216 - $this->current_valid_urls,
2217 - $system_instruction_urls[0]
2218 - );
2219 - // Remove duplicates
2220 - $this->current_valid_urls = array_unique($this->current_valid_urls);
2221 -
2222 - //error_log("Added " . count($system_instruction_urls[0]) . " URLs from system instructions");
1239 + // SAVE TO TRANSCRIPT FIRST
1240 + if (!empty($this->fallbackResponse['text'])) {
1241 + $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['text']);
2223 1242 }
2224 - }
2225 -
2226 -// ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
2227 -if ($testing_data !== null && $this->last_similarity_analysis !== null) {
2228 - // Update testing data with the REAL similarity analysis
2229 - $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
2230 - $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2231 - $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
2232 - $testing_data['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2233 - $testing_data['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2234 -}
2235 -// ===== END SIMILARITY DATA CAPTURE =====
2236 -
2237 -// NEW: Add valid URLs to testing data for admin panel display (AFTER similarity data)
2238 -if ($testing_data !== null && !empty($this->current_valid_urls)) {
2239 - $testing_data['approved_urls'] = array_values($this->current_valid_urls);
2240 - //error_log("Added " . count($this->current_valid_urls) . " approved URLs to testing data");
2241 -}
2242 -
2243 - $kb_block = !empty($relevant_content)
2244 - ? "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n"
2245 - : "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
2246 -
2247 - // {context} placeholder (plan 59bc1b): when the resolved instructions
2248 - // carry the token, the KB block is injected at that spot by
2249 - // get_system_instructions() (every provider handler re-calls it) and is
2250 - // NOT appended here — otherwise the block would ride twice.
2251 - // $system_instructions above was resolved while context_kb_block was
2252 - // still null, so the literal token is still visible for this check.
2253 - if (!empty($system_instructions) && stripos($system_instructions, '{context}') !== false) {
2254 - $this->context_kb_block = $kb_block;
2255 - } else {
2256 - $context_content .= $kb_block;
2257 - }
2258 -
2259 - // NEW: Add approved URLs list to context for AI (only if citation links enabled)
2260 - if ($citation_links_enabled && !empty($this->current_valid_urls)) {
2261 - $context_content .= "===== APPROVED URLS FOR CITATIONS =====\n";
2262 - $context_content .= "You may ONLY use these exact URLs in your response:\n";
2263 - foreach ($this->current_valid_urls as $url) {
2264 - $context_content .= "- " . $url . "\n";
1243 + if (!empty($this->fallbackResponse['html'])) {
1244 + $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2265 1245 }
2266 - $context_content .= "\nCRITICAL: Do NOT create, modify, extend, or invent any other URLs. ";
2267 - $context_content .= "===== END APPROVED URLS =====\n\n";
2268 - }
2269 -
2270 - // Check for and include PDF content
2271 - $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
2272 - $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
2273 - $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
2274 - if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
2275 - $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
2276 - if (!empty($relevant_pdf_pages)) {
2277 - $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
2278 - foreach ($relevant_pdf_pages as $page_data) {
2279 - $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
2280 - }
2281 - $context_content .= "\n";
1246 +
1247 + $response_data = [
1248 + 'text' => $this->fallbackResponse['text'] ?? '',
1249 + 'html' => $this->fallbackResponse['html'] ?? '',
1250 + 'session_id' => $session_id
1251 + ];
1252 +
1253 + if (isset($this->fallbackResponse['chat_mode'])) {
1254 + $response_data['chat_mode'] = $this->fallbackResponse['chat_mode'];
2282 1255 }
1256 +
1257 + if ($testing_data !== null) {
1258 + $response_data['testing_data'] = $testing_data;
1259 + }
1260 +
1261 + // Clear streaming headers if they were set
1262 + if ($is_streaming) {
1263 + header_remove('Content-Type');
1264 + header_remove('Cache-Control');
1265 + header_remove('Connection');
1266 + header_remove('X-Accel-Buffering');
1267 + header('Content-Type: application/json');
1268 + }
1269 +
1270 + wp_send_json($response_data);
1271 + wp_die();
2283 1272 }
1273 + }
2284 1274
2285 - // Check for and include Word content
2286 - $word_url = get_transient('mxchat_word_url_' . $session_id);
2287 - $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
2288 - $word_filename = get_transient('mxchat_word_filename_' . $session_id);
2289 - if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
2290 - $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
2291 - if (!empty($relevant_word_chunks)) {
2292 - $context_content .= "Relevant content from Word document '{$word_filename}':\n";
2293 - foreach ($relevant_word_chunks as $chunk_data) {
2294 - $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
2295 - }
2296 - $context_content .= "\n";
2297 - }
2298 - }
1275 + // If we get here, no intent matched OR the intent didn't provide a usable response
1276 +
1277 + // Step 4: Generate AI response
1278 + $conversation_history = $this->mxchat_fetch_conversation_history_for_ai($session_id);
1279 + $this->mxchat_increment_chat_count();
1280 +
1281 + // Generate embedding for the user's query
1282 + $user_message_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
1283 +
1284 + // Check if the embedding generation returned an error
1285 + if (is_array($user_message_embedding) && isset($user_message_embedding['error'])) {
1286 + $error_message = $user_message_embedding['error'];
1287 + $error_code = $user_message_embedding['error_code'] ?? 'embedding_error';
2299 1288
2300 - $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
1289 + wp_send_json_error([
1290 + 'error_message' => $error_message,
1291 + 'error_code' => $error_code
1292 + ]);
1293 + wp_die();
1294 + }
1295 +
1296 + // Check if the embedding is valid
1297 + if (!is_array($user_message_embedding) || empty($user_message_embedding)) {
1298 + wp_send_json_error([
1299 + 'error_message' => esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat'),
1300 + 'error_code' => 'invalid_embedding'
1301 + ]);
1302 + wp_die();
1303 + }
2301 1304
2302 - // Extract model from current options for bot-specific model support
2303 - $selected_model = isset($current_options['model']) ? $current_options['model'] : 'gpt-5.6-sol';
1305 + // Build context with both knowledge base and PDF content if available
1306 + $context_content = "User asked: '{$message}'\n\n";
1307 +
1308 + // NEW: Add action instruction if present (add this right after the above line)
1309 + if (!empty($this->current_action_instruction)) {
1310 + $context_content .= "===== SPECIAL INSTRUCTION =====\n";
1311 + $context_content .= "IMPORTANT: " . $this->current_action_instruction . "\n";
1312 + $context_content .= "Respond naturally and conversationally while following this instruction.\n";
1313 + $context_content .= "===== END SPECIAL INSTRUCTION =====\n\n";
1314 +
1315 + // Clear the instruction after using it
1316 + $this->current_action_instruction = null;
1317 + }
2304 1318
2305 - // ===== Native function-calling fallback (plan-mxchat-20260617-a41dee) =====
2306 - // Intents already missed (we're past the intent router). If function
2307 - // calling is enabled and the active model is tool-capable, let the model
2308 - // SELECT and run registered callbacks as tools — independent of intents,
2309 - // works with zero Actions. The tool round is buffered; the final answer is
2310 - // emitted via the SAME envelopes the normal path uses. Default-off, so
2311 - // existing installs never enter this branch.
2312 - if ($this->mxchat_fc_should_run($selected_model)) {
2313 - $fc_outcome = $this->mxchat_fc_attempt(
2314 - $message,
2315 - $context_content,
2316 - $conversation_history,
2317 - $selected_model,
2318 - $current_options,
2319 - $session_id,
2320 - $user_id
2321 - );
2322 - if (is_array($fc_outcome) && !empty($fc_outcome['handled'])) {
2323 - $fc_text = isset($fc_outcome['text']) ? $fc_outcome['text'] : '';
2324 - if (!empty($this->current_valid_urls)) {
2325 - $fc_text = $this->validate_and_clean_urls($fc_text, $this->current_valid_urls, $session_id, $bot_id);
2326 - }
2327 - // plan-mxchat-20260617-48a57a — surface any UI element a tool
2328 - // produced (generated image / product card / image gallery) so the
2329 - // widget RENDERS it, instead of emitting only the model's text.
2330 - // The html was already saved to the transcript in
2331 - // mxchat_fc_execute_tool (or by the callback itself for self-saving
2332 - // core tools), so we persist ONLY the model's caption text here.
2333 - $fc_html = isset($this->fc_ui_html) ? $this->fc_ui_html : '';
2334 1319
2335 - if ($fc_text !== '') {
2336 - $this->mxchat_save_chat_message($session_id, 'bot', $fc_text, null, null);
2337 - }
1320 + // NEW: Add page context if available and contextual awareness is enabled
1321 + if ($page_context && isset($this->options['contextual_awareness_toggle']) && $this->options['contextual_awareness_toggle'] === 'on') {
1322 + $context_content .= "===== CURRENT PAGE CONTEXT =====\n";
1323 + $context_content .= "Page URL: " . $page_context['url'] . "\n";
1324 + $context_content .= "Page Title: " . $page_context['title'] . "\n";
1325 + $context_content .= "Page Content: " . $page_context['content'] . "\n";
1326 + $context_content .= "===== END CURRENT PAGE CONTEXT =====\n\n";
1327 + }
2338 1328
2339 - // A video-backed KB source queued during retrieval (03ba33) must
2340 - // surface on the FC path too — the FC envelopes below are the ONLY
2341 - // exit for this turn, so append it to the html channel and persist
2342 - // it (tool html was already saved in mxchat_fc_execute_tool; the
2343 - // video embed has no other save point on this path).
2344 - if (!empty($this->videoEmbedHtml)) {
2345 - $fc_html .= $this->videoEmbedHtml;
2346 - $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2347 - }
1329 + // Get relevant content from knowledge base - THIS IS WHERE THE SIMILARITY ANALYSIS HAPPENS
1330 + $relevant_content = $this->mxchat_find_relevant_content($user_message_embedding);
1331 +
1332 + // ===== CAPTURE REAL SIMILARITY DATA FOR ADMINS =====
1333 + if ($testing_data !== null && $this->last_similarity_analysis !== null) {
1334 + // Update testing data with the REAL similarity analysis
1335 + $testing_data['top_matches'] = $this->last_similarity_analysis['top_matches'];
1336 + $testing_data['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
1337 + $testing_data['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'];
1338 + }
1339 + // ===== END SIMILARITY DATA CAPTURE =====
1340 +
1341 + if (!empty($relevant_content)) {
1342 + $context_content .= "===== OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n" . $relevant_content . "\n===== END OF OFFICIAL KNOWLEDGE DATABASE CONTENT =====\n\n";
1343 + } else {
1344 + $context_content .= "===== NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE =====\n";
1345 + }
2348 1346
2349 - if ($is_streaming) {
2350 - // The frontend SSE reader routes any event carrying text/html
2351 - // to handleNonStreamResponse(), which renders text + html in a
2352 - // single bot message — so emit one complete event (mirrors the
2353 - // intent path's text/html envelope).
2354 - $sse = array('session_id' => $session_id);
2355 - if ($fc_text !== '') $sse['text'] = $fc_text;
2356 - if ($fc_html !== '') $sse['html'] = $fc_html;
2357 - if ($fc_text === '' && $fc_html === '') $sse['text'] = $this->mxchat_fc_giveup_text();
2358 - echo "data: " . wp_json_encode($sse) . "\n\n";
2359 - echo "data: [DONE]\n\n";
2360 - flush();
2361 - } else {
2362 - $fc_response_data = array('text' => $fc_text, 'html' => $fc_html, 'session_id' => $session_id);
2363 - if ($testing_data !== null) {
2364 - $fc_response_data['testing_data'] = $testing_data;
2365 - }
2366 - wp_send_json($fc_response_data);
2367 - }
2368 - wp_die();
1347 + // Check for and include PDF content
1348 + $pdf_url = get_transient('mxchat_pdf_url_' . $session_id);
1349 + $pdf_embeddings = get_transient('mxchat_pdf_embeddings_' . $session_id);
1350 + $pdf_filename = get_transient('mxchat_pdf_filename_' . $session_id);
1351 + if ($pdf_url && $pdf_embeddings && get_transient('mxchat_include_pdf_in_context_' . $session_id)) {
1352 + $relevant_pdf_pages = $this->find_relevant_pdf_pages($user_message_embedding, $pdf_embeddings);
1353 + if (!empty($relevant_pdf_pages)) {
1354 + $context_content .= "Relevant content from PDF document '{$pdf_filename}':\n";
1355 + foreach ($relevant_pdf_pages as $page_data) {
1356 + $context_content .= "Page {$page_data['page_number']} of '{$pdf_filename}': {$page_data['text']}\n";
2369 1357 }
1358 + $context_content .= "\n";
2370 1359 }
2371 - // ===== end function-calling fallback =====
1360 + }
2372 1361
2373 - // Streaming + a queued video embed (03ba33): the provider handlers own the
2374 - // token stream and the [DONE] terminator, so the embed rides a dedicated
2375 - // append_html SSE event emitted BEFORE the stream starts. The client
2376 - // stashes it and appends it as its own bot bubble after [DONE] — old
2377 - // cached widget JS simply ignores the unknown key (no content/text/html/
2378 - // error field, so no branch matches). Transcript save happens after the
2379 - // stream completes, so history order matches the live order (text, then
2380 - // embed).
2381 - if ($is_streaming && !empty($this->videoEmbedHtml)) {
2382 - echo "data: " . wp_json_encode(array(
2383 - 'append_html' => $this->videoEmbedHtml,
2384 - 'session_id' => $session_id,
2385 - )) . "\n\n";
2386 - flush();
2387 - }
2388 -
2389 - $response = $this->mxchat_generate_response(
2390 - $context_content,
2391 - $current_options['api_key'] ?? $this->options['api_key'],
2392 - $current_options['xai_api_key'] ?? $this->options['xai_api_key'],
2393 - $current_options['claude_api_key'] ?? $this->options['claude_api_key'],
2394 - $current_options['deepseek_api_key'] ?? $this->options['deepseek_api_key'],
2395 - $current_options['gemini_api_key'] ?? $this->options['gemini_api_key'],
2396 - $current_options['openrouter_api_key'] ?? $this->options['openrouter_api_key'],
2397 - $conversation_history,
2398 - $is_streaming,
2399 - $session_id,
2400 - $testing_data,
2401 - $selected_model
2402 - );
2403 -
2404 - // Handle streaming vs non-streaming responses
2405 - if ($is_streaming) {
2406 - // Check if streaming actually happened or if it fell back to regular response
2407 - if ($response === true) {
2408 - // Persist the video embed AFTER the provider saved the streamed
2409 - // text, so history replays in the same order the visitor saw
2410 - // (text bubble, then embed bubble). See 03ba33.
2411 - if (!empty($this->videoEmbedHtml)) {
2412 - $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2413 - }
2414 - wp_die();
1362 + // Check for and include Word content
1363 + $word_url = get_transient('mxchat_word_url_' . $session_id);
1364 + $word_embeddings = get_transient('mxchat_word_embeddings_' . $session_id);
1365 + $word_filename = get_transient('mxchat_word_filename_' . $session_id);
1366 + if ($word_url && $word_embeddings && get_transient('mxchat_include_word_in_context_' . $session_id)) {
1367 + $relevant_word_chunks = $this->word_handler->mxchat_find_relevant_word_chunks($user_message_embedding, $word_embeddings);
1368 + if (!empty($relevant_word_chunks)) {
1369 + $context_content .= "Relevant content from Word document '{$word_filename}':\n";
1370 + foreach ($relevant_word_chunks as $chunk_data) {
1371 + $context_content .= "Section {$chunk_data['chunk_number']} of '{$word_filename}': {$chunk_data['text']}\n";
2415 1372 }
2416 - // If we get here, streaming fell back to regular response, continue
2417 - // But if there's an error, we need to send it as SSE format since headers are already set
2418 - if (is_array($response) && isset($response['error'])) {
2419 - $error_message = $response['error'];
2420 - $error_code = $response['error_code'] ?? 'api_error';
2421 - // Send error in SSE format that the client JS can handle
2422 - echo "data: " . json_encode([
2423 - 'error' => true,
2424 - 'error_message' => $error_message,
2425 - 'error_code' => $error_code,
2426 - 'text' => $error_message, // Also include as text for fallback handling
2427 - 'message' => $error_message
2428 - ]) . "\n\n";
2429 - echo "data: [DONE]\n\n";
2430 - flush();
2431 - wp_die();
2432 - }
1373 + $context_content .= "\n";
2433 1374 }
1375 + }
1376 +
1377 + $context_content = apply_filters('mxchat_prepare_context', $context_content, $session_id);
2434 1378
2435 - // Check if the response is an error array (non-streaming mode)
2436 - if (is_array($response) && isset($response['error'])) {
2437 - wp_send_json_error([
2438 - 'error_message' => $response['error'],
2439 - 'error_code' => $response['error_code'] ?? 'api_error'
2440 - ]);
1379 + // Generate response
1380 + $response = $this->mxchat_generate_response(
1381 + $context_content,
1382 + $this->options['api_key'],
1383 + $this->options['xai_api_key'],
1384 + $this->options['claude_api_key'],
1385 + $this->options['deepseek_api_key'],
1386 + $this->options['gemini_api_key'],
1387 + $conversation_history,
1388 + $is_streaming,
1389 + $session_id,
1390 + $testing_data
1391 + );
1392 +
1393 + // Handle streaming vs non-streaming responses
1394 + if ($is_streaming) {
1395 + // Check if streaming actually happened or if it fell back to regular response
1396 + if ($response === true) {
2441 1397 wp_die();
2442 1398 }
1399 + // If we get here, streaming fell back to regular response, continue
1400 + }
2443 1401
2444 - // DEBUG: Check what we have
2445 - //error_log("=== BEFORE URL VALIDATION ===");
2446 - //error_log("current_valid_urls is empty? " . (empty($this->current_valid_urls) ? 'YES' : 'NO'));
2447 - //error_log("current_valid_urls count: " . count($this->current_valid_urls));
2448 - //error_log("current_valid_urls content: " . print_r($this->current_valid_urls, true));
2449 -
2450 - // If we get here, the response is valid text - now validate URLs
2451 - if (!empty($this->current_valid_urls)) {
2452 - //error_log("CALLING validate_and_clean_urls");
2453 - $response = $this->validate_and_clean_urls($response, $this->current_valid_urls, $session_id, $bot_id);
2454 - } else {
2455 - //error_log("SKIPPING validation - current_valid_urls is empty");
2456 - }
2457 - // ===== END URL VALIDATION =====
2458 -
2459 - // Prepare RAG context data for storage (only include documents used for context)
2460 - $rag_context_for_storage = null;
2461 - $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
2462 - $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
2463 -
2464 - if ($has_rag_data || $has_action_data) {
2465 - $rag_context_for_storage = [];
2466 -
2467 - // Add RAG/source data if available
2468 - if ($has_rag_data) {
2469 - $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
2470 - $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
2471 - $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
2472 - $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
2473 - $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
2474 - $rag_context_for_storage['sources_used'] = $this->last_similarity_analysis['sources_used'] ?? 0;
2475 - $rag_context_for_storage['total_chunks_used'] = $this->last_similarity_analysis['total_chunks_used'] ?? 0;
2476 - }
2477 -
2478 - // Add action analysis data if available
2479 - if ($has_action_data) {
2480 - $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
2481 - }
2482 - }
2483 -
2484 - // Save the cleaned response with RAG context
2485 - $this->mxchat_save_chat_message($session_id, 'bot', $response, null, $rag_context_for_storage);
2486 -
2487 - // Step 5: Save additional content if available
2488 - if (!empty($this->productCardHtml)) {
2489 - $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
2490 - }
2491 -
2492 - if (!empty($this->fallbackResponse['html'])) {
2493 - $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2494 - }
2495 -
2496 - if (!empty($this->videoEmbedHtml)) {
2497 - $this->mxchat_save_chat_message($session_id, 'bot', $this->videoEmbedHtml);
2498 - }
2499 -
2500 - // Step 6: Return the response
2501 - // DEBUG: Check if newlines exist in the response
2502 - //error_log("=== MXCHAT NON-STREAMING RESPONSE DEBUG ===");
2503 - //error_log("Response has newlines: " . (strpos($response, "\n") !== false ? 'YES' : 'NO'));
2504 - //error_log("Response first 500 chars: " . substr($response, 0, 500));
2505 -
2506 - // Product cards and action html keep their existing either/or precedence;
2507 - // a queued video embed (03ba33) is APPENDED so it can coexist with both.
2508 - $additional_html = !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? '');
2509 - if (!empty($this->videoEmbedHtml)) {
2510 - $additional_html .= $this->videoEmbedHtml;
2511 - }
2512 -
2513 - $response_data = [
2514 - 'text' => $response,
2515 - 'html' => $additional_html,
2516 - 'session_id' => $session_id
2517 - ];
2518 -
2519 - // Include vectorstore error info for admin debugging (only visible to admins via testing_data)
2520 - if (!empty($this->last_vectorstore_error) && $testing_data !== null) {
2521 - $testing_data['vectorstore_error'] = $this->last_vectorstore_error;
2522 - }
2523 -
2524 - // Also pass it as a top-level field so JS can show a better error message to admins
2525 - if (!empty($this->last_vectorstore_error) && current_user_can('manage_options')) {
2526 - $response_data['vectorstore_error'] = $this->last_vectorstore_error;
2527 - }
2528 -
2529 - // Always add testing data for admins (no toggle needed)
2530 - if ($testing_data !== null) {
2531 - $response_data['testing_data'] = $testing_data;
2532 - }
2533 -
2534 - wp_send_json($response_data);
1402 + // Check if the response is an error array
1403 + if (is_array($response) && isset($response['error'])) {
1404 + wp_send_json_error([
1405 + 'error_message' => $response['error'],
1406 + 'error_code' => $response['error_code'] ?? 'api_error'
1407 + ]);
2535 1408 wp_die();
2536 -}
1409 + }
1410 +
1411 + // If we get here, the response is valid text
1412 + $this->mxchat_save_chat_message($session_id, 'bot', $response);
2537 1413
2538 -/**
2539 - * Get bot-specific options for multi-bot functionality
2540 - * Falls back to default options if bot_id is 'default' or multi-bot add-on is not active
2541 - */
2542 -// Also debug the bot options retrieval
2543 -private function get_bot_options($bot_id = 'default') {
2544 - //error_log("MXCHAT DEBUG: get_bot_options called for bot: " . $bot_id);
2545 -
2546 - // The admin Testing tab renders the real widget as bot_id "testing", which
2547 - // is not a registered multi-bot. It must resolve the DEFAULT bot's config
2548 - // so the Testing chat behaves exactly like the front-end (same precedent
2549 - // as the Actions enabled_bots check).
2550 - if ($bot_id === 'testing') {
2551 - $bot_id = 'default';
1414 + // Step 5: Save additional content if available
1415 + if (!empty($this->productCardHtml)) {
1416 + $this->mxchat_save_chat_message($session_id, 'bot', $this->productCardHtml);
2552 1417 }
2553 1418
2554 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2555 - //error_log("MXCHAT DEBUG: Using default options (no multi-bot or bot is 'default')");
2556 - return array();
1419 + if (!empty($this->fallbackResponse['html'])) {
1420 + $this->mxchat_save_chat_message($session_id, 'bot', $this->fallbackResponse['html']);
2557 1421 }
2558 -
2559 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
2560 -
2561 - if (!empty($bot_options)) {
2562 - //error_log("MXCHAT DEBUG: Got bot-specific options from filter");
2563 - if (isset($bot_options['similarity_threshold'])) {
2564 - //error_log(" - similarity_threshold: " . $bot_options['similarity_threshold']);
2565 - }
2566 - }
2567 -
2568 - return is_array($bot_options) ? $bot_options : array();
2569 -}
2570 1422
2571 -/**
2572 - * Get bot-specific Pinecone configuration
2573 - * Used in the knowledge retrieval functions
2574 - */
2575 -// Also add debugging to your get_bot_pinecone_config function
2576 -private function get_bot_pinecone_config($bot_id = 'default') {
2577 - //error_log("MXCHAT DEBUG: get_bot_pinecone_config called for bot: " . $bot_id);
1423 + // Step 6: Return the response
1424 + $response_data = [
1425 + 'text' => $response,
1426 + 'html' => !empty($this->productCardHtml) ? $this->productCardHtml : ($this->fallbackResponse['html'] ?? ''),
1427 + 'session_id' => $session_id
1428 + ];
2578 1429
2579 - // Admin Testing tab bot → resolve the DEFAULT bot's backend. Without this,
2580 - // on a multi-bot + Pinecone site the filter below gets an unknown bot id
2581 - // with an EMPTY default, returns array(), and the dispatcher silently
2582 - // searches the WordPress DB while the front-end searches Pinecone — the
2583 - // Testing panel then reports similarity results from a different KB.
2584 - if ($bot_id === 'testing') {
2585 - $bot_id = 'default';
1430 + // Always add testing data for admins (no toggle needed)
1431 + if ($testing_data !== null) {
1432 + $response_data['testing_data'] = $testing_data;
2586 1433 }
2587 1434
2588 - // If default bot or multi-bot add-on not active, use default Pinecone config
2589 - if ($bot_id === 'default' || !class_exists('MxChat_Multi_Bot_Manager')) {
2590 - //error_log("MXCHAT DEBUG: Using default Pinecone config (no multi-bot or bot is 'default')");
2591 - $addon_options = get_option('mxchat_pinecone_addon_options', array());
2592 - $config = array(
2593 - 'use_pinecone' => (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1'),
2594 - 'api_key' => $addon_options['mxchat_pinecone_api_key'] ?? '',
2595 - 'host' => $addon_options['mxchat_pinecone_host'] ?? '',
2596 - 'namespace' => $addon_options['mxchat_pinecone_namespace'] ?? ''
2597 - );
2598 - //error_log("MXCHAT DEBUG: Default config - use_pinecone: " . ($config['use_pinecone'] ? 'true' : 'false'));
2599 - return $config;
2600 - }
2601 -
2602 - //error_log("MXCHAT DEBUG: Calling filter 'mxchat_get_bot_pinecone_config' for bot: " . $bot_id);
2603 -
2604 - // Hook for multi-bot add-on to provide bot-specific Pinecone config
2605 - $bot_pinecone_config = apply_filters('mxchat_get_bot_pinecone_config', array(), $bot_id);
2606 -
2607 - if (!empty($bot_pinecone_config)) {
2608 - //error_log("MXCHAT DEBUG: Got bot-specific config from filter");
2609 - //error_log(" - use_pinecone: " . (isset($bot_pinecone_config['use_pinecone']) ? ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false') : 'not set'));
2610 - //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'not set'));
2611 - //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'not set'));
2612 - } else {
2613 - //error_log("MXCHAT DEBUG: Filter returned empty config!");
2614 - }
2615 -
2616 - return is_array($bot_pinecone_config) ? $bot_pinecone_config : array();
1435 + wp_send_json($response_data);
1436 + wp_die();
2617 1437 }
2618 1438
2619 -
2620 1439 // Updated function to check intents and invoke the callback function
2621 1440 private function mxchat_check_intent_and_invoke_callback($message, $user_id, $session_id) {
2622 1441 global $wpdb;
2623 1442 $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
2624 1443
2625 - // Get the current bot_id
2626 - $current_bot_id = $this->get_current_bot_id($session_id);
2627 -
2628 1444 // Generate the user embedding
2629 1445 $user_embedding = $this->mxchat_generate_embedding($message, $this->options['api_key']);
2630 -
1446 +
2631 1447 // Check if embedding generation returned an error
2632 1448 if (is_array($user_embedding) && isset($user_embedding['error'])) {
2633 1449 $error_message = $user_embedding['error'];
2634 1450 $error_code = $user_embedding['error_code'] ?? 'embedding_error';
2635 -
2636 - // FIXED: Send error in appropriate format based on streaming mode
2637 - if ($this->is_streaming) {
2638 - echo "data: " . json_encode([
2639 - 'error' => true,
2640 - 'error_message' => $error_message,
2641 - 'error_code' => $error_code,
2642 - 'text' => $error_message,
2643 - 'message' => $error_message
2644 - ]) . "\n\n";
2645 - echo "data: [DONE]\n\n";
2646 - flush();
2647 - } else {
2648 - wp_send_json_error([
2649 - 'error_message' => $error_message,
2650 - 'error_code' => $error_code
2651 - ]);
2652 - }
1451 +
1452 + wp_send_json_error([
1453 + 'error_message' => $error_message,
1454 + 'error_code' => $error_code
1455 + ]);
2653 1456 wp_die();
2654 1457 }
2655 -
1458 +
2656 1459 // Check if embedding is valid
2657 1460 if (!is_array($user_embedding) || empty($user_embedding)) {
2658 - $error_message = esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat');
2659 -
2660 - // FIXED: Send error in appropriate format based on streaming mode
2661 - if ($this->is_streaming) {
2662 - echo "data: " . json_encode([
2663 - 'error' => true,
2664 - 'error_message' => $error_message,
2665 - 'error_code' => 'invalid_embedding',
2666 - 'text' => $error_message,
2667 - 'message' => $error_message
2668 - ]) . "\n\n";
2669 - echo "data: [DONE]\n\n";
2670 - flush();
2671 - } else {
2672 - wp_send_json_error([
2673 - 'error_message' => $error_message,
2674 - 'error_code' => 'invalid_embedding'
2675 - ]);
2676 - }
1461 + wp_send_json_error([
1462 + 'error_message' => esc_html__('Unable to process your message. The embedding service is not responding correctly.', 'mxchat'),
1463 + 'error_code' => 'invalid_embedding'
1464 + ]);
2677 1465 wp_die();
2678 1466 }
2679 -
1467 +
2680 1468 // Fetch intents from the database
2681 1469 $table_name = $wpdb->prefix . 'mxchat_intents';
2682 1470 if ($chat_mode === 'agent') {
2683 1471 $query = $wpdb->prepare(
@@ -2687,29 +1475,19 @@
2687 1475 $intents = $wpdb->get_results($query);
2688 1476 } else {
2689 1477 $intents = $wpdb->get_results("SELECT * FROM $table_name WHERE enabled = 1 OR enabled IS NULL");
2690 1478 }
2691 -
1479 +
2692 1480 if (empty($intents)) {
2693 1481 return false;
2694 1482 }
2695 -
2696 - // Prefetch individual phrase vectors from wp_mxchat_intent_phrases (grouped by intent_id)
2697 - $phrases_table = $wpdb->prefix . 'mxchat_intent_phrases';
2698 - $phrases_by_intent = [];
2699 - if ($wpdb->get_var("SHOW TABLES LIKE '$phrases_table'") === $phrases_table) {
2700 - $all_phrases = $wpdb->get_results("SELECT intent_id, phrase, embedding_vector FROM $phrases_table");
2701 - foreach ($all_phrases as $p) {
2702 - $phrases_by_intent[$p->intent_id][] = $p;
2703 - }
2704 - }
2705 -
1483 +
2706 1484 $highest_similarity = -INF;
2707 1485 $matched_intent = null;
2708 -
2709 - // Array to store action analysis for testing panel
1486 +
1487 + // NEW: Array to store action analysis for testing panel
2710 1488 $action_analysis = [];
2711 -
1489 +
2712 1490 foreach ($intents as $intent) {
2713 1491 // Additional check for enabled state
2714 1492 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2715 1493 if (!$is_enabled) {
@@ -2714,57 +1492,22 @@
2714 1492 $is_enabled = isset($intent->enabled) ? (bool)$intent->enabled : true;
2715 1493 if (!$is_enabled) {
2716 1494 continue;
2717 1495 }
2718 -
2719 - // Check if this action is enabled for the current bot
2720 - if (!$this->is_action_enabled_for_bot($intent, $current_bot_id)) {
2721 - continue;
2722 - }
2723 -
2724 - $best_similarity = -INF;
2725 - $matched_phrase_text = '';
2726 -
2727 - // Check legacy embedding vector (existing behavior)
1496 +
2728 1497 $intent_embedding_serialized = $intent->embedding_vector;
2729 1498 $intent_embedding = $intent_embedding_serialized
2730 1499 ? unserialize($intent_embedding_serialized, ['allowed_classes' => false])
2731 1500 : null;
2732 -
2733 - if (is_array($intent_embedding) && !empty($intent_embedding)) {
2734 - $legacy_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2735 - if ($legacy_similarity > $best_similarity) {
2736 - $best_similarity = $legacy_similarity;
2737 - $matched_phrase_text = 'legacy';
2738 - }
2739 - }
2740 -
2741 - // Check individual phrase vectors
2742 - if (isset($phrases_by_intent[$intent->id])) {
2743 - foreach ($phrases_by_intent[$intent->id] as $phrase_row) {
2744 - $phrase_embedding = $phrase_row->embedding_vector
2745 - ? unserialize($phrase_row->embedding_vector, ['allowed_classes' => false])
2746 - : null;
2747 - if (!is_array($phrase_embedding)) {
2748 - continue;
2749 - }
2750 - $phrase_similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $phrase_embedding);
2751 - if ($phrase_similarity > $best_similarity) {
2752 - $best_similarity = $phrase_similarity;
2753 - $matched_phrase_text = $phrase_row->phrase;
2754 - }
2755 - }
2756 - }
2757 -
2758 - // Skip if no valid embedding was found at all
2759 - if ($best_similarity === -INF) {
1501 +
1502 + if (!is_array($intent_embedding)) {
2760 1503 continue;
2761 1504 }
2762 -
2763 - $similarity = $best_similarity;
1505 +
1506 + $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $intent_embedding);
2764 1507 $intent_threshold = isset($intent->similarity_threshold) ? $intent->similarity_threshold : 0.85;
2765 -
2766 - // Store action analysis data for testing panel
1508 +
1509 + // NEW: Store action analysis data for testing panel
2767 1510 $action_analysis[] = [
2768 1511 'intent_label' => $intent->intent_label,
2769 1512 'callback_function' => $intent->callback_function,
2770 1513 'similarity' => round($similarity, 4),
@@ -2771,12 +1514,11 @@
2771 1514 'similarity_percentage' => round($similarity * 100, 2),
2772 1515 'threshold' => $intent_threshold,
2773 1516 'threshold_percentage' => round($intent_threshold * 100, 2),
2774 1517 'above_threshold' => $similarity >= $intent_threshold,
2775 - 'matched_phrase' => $matched_phrase_text,
2776 1518 'triggered' => false // Will be updated below if this intent is triggered
2777 1519 ];
2778 -
1520 +
2779 1521 if ($similarity >= $intent_threshold && $similarity > $highest_similarity) {
2780 1522 $highest_similarity = $similarity;
2781 1523 $matched_intent = $intent;
2782 1524 }
@@ -2781,9 +1523,9 @@
2781 1523 $matched_intent = $intent;
2782 1524 }
2783 1525 }
2784 1526
2785 - // Mark the triggered action if any
1527 + // NEW: Mark the triggered action if any
2786 1528 if ($matched_intent) {
2787 1529 foreach ($action_analysis as &$action) {
2788 1530 if ($action['intent_label'] === $matched_intent->intent_label) {
2789 1531 $action['triggered'] = true;
@@ -2791,9 +1533,9 @@
2791 1533 }
2792 1534 }
2793 1535 }
2794 1536
2795 - // Sort actions by similarity (highest first) and store for testing panel
1537 + // NEW: Sort actions by similarity (highest first) and store for testing panel
2796 1538 usort($action_analysis, function($a, $b) {
2797 1539 return $b['similarity'] <=> $a['similarity'];
2798 1540 });
2799 1541
@@ -2800,73 +1542,47 @@
2800 1542 // Store action analysis for testing panel capture
2801 1543 $this->last_action_analysis = $action_analysis;
2802 1544
2803 1545 // Around line 715 in your mxchat_check_intent_and_invoke_callback function
2804 - if ($matched_intent) {
2805 - // If the callback is a method on this instance (core callback), call it directly
2806 - if (method_exists($this, $matched_intent->callback_function)) {
2807 - $callback_result = call_user_func(
2808 - [$this, $matched_intent->callback_function],
2809 - $message,
2810 - $user_id,
2811 - $session_id,
2812 - $matched_intent,
2813 - $user_context ?? null
2814 - );
1546 +if ($matched_intent) {
1547 + // If the callback is a method on this instance (core callback), call it directly
1548 + if (method_exists($this, $matched_intent->callback_function)) {
1549 + $callback_result = call_user_func(
1550 + [$this, $matched_intent->callback_function],
1551 + $message,
1552 + $user_id,
1553 + $session_id,
1554 + $matched_intent,
1555 + $user_context ?? null
1556 + );
1557 + } else {
1558 + // Otherwise, use apply_filters for add-on callbacks
1559 + $callback_result = apply_filters(
1560 + $matched_intent->callback_function,
1561 + false,
1562 + $message,
1563 + $user_id,
1564 + $session_id,
1565 + $matched_intent
1566 + );
1567 + }
1568 +
1569 + // Handle the callback result properly
1570 + if ($callback_result !== false) {
1571 + // If callback returned an array with chat_mode, use it directly
1572 + if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
1573 + $this->fallbackResponse = $callback_result;
1574 + return $callback_result; // Return the full array
2815 1575 } else {
2816 - // Otherwise, use apply_filters for add-on callbacks
2817 - $callback_result = apply_filters(
2818 - $matched_intent->callback_function,
2819 - false,
2820 - $message,
2821 - $user_id,
2822 - $session_id,
2823 - $matched_intent
2824 - );
1576 + $this->fallbackResponse = $callback_result;
1577 + return true;
2825 1578 }
2826 -
2827 - // Handle the callback result properly
2828 - if ($callback_result !== false) {
2829 - // If callback returned an array with chat_mode, use it directly
2830 - if (is_array($callback_result) && isset($callback_result['chat_mode'])) {
2831 - $this->fallbackResponse = $callback_result;
2832 - return $callback_result; // Return the full array
2833 - } else {
2834 - $this->fallbackResponse = $callback_result;
2835 - return true;
2836 - }
2837 - }
2838 1579 }
1580 +}
2839 1581
2840 1582 return false;
2841 1583 }
2842 1584
2843 -/**
2844 - * Check if an action is enabled for a specific bot
2845 - */
2846 -private function is_action_enabled_for_bot($intent, $bot_id) {
2847 - // If enabled_bots column doesn't exist or is null, assume it's enabled for all bots (backward compatibility)
2848 - if (!isset($intent->enabled_bots) || empty($intent->enabled_bots)) {
2849 - return true;
2850 - }
2851 -
2852 - $enabled_bots = json_decode($intent->enabled_bots, true);
2853 -
2854 - // If JSON decode fails or returns empty array, assume enabled for all (backward compatibility)
2855 - if (!is_array($enabled_bots) || empty($enabled_bots)) {
2856 - return true;
2857 - }
2858 -
2859 - // Admin testing tab uses bot_id "testing" — treat it as "default" so all
2860 - // default-bot actions are testable from the admin panel
2861 - if ($bot_id === 'testing') {
2862 - $bot_id = 'default';
2863 - }
2864 -
2865 - // Check if the current bot is in the enabled bots list
2866 - return in_array($bot_id, $enabled_bots);
2867 -}
2868 -
2869 1585 // Helper function to clear PDF and Word document related transients
2870 1586 private function clear_pdf_transients($session_id) {
2871 1587 // PDF transients
2872 1588 delete_transient('mxchat_pdf_url_' . $session_id);
@@ -2900,23 +1616,18 @@
2900 1616 }
2901 1617
2902 1618 public function mxchat_generate_image($message, $user_id, $session_id) {
2903 1619 //error_log("Starting image generation for message: " . $message);
2904 -
2905 - // Prepare a prompt for OpenAI image generation
1620 +
1621 + // Prepare a prompt for DALL-E
2906 1622 $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2907 -
2908 - // Opt-in routing: when 'custom_provider_for_images' is on, route image gen
2909 - // through the configured Custom (OpenAI-compatible) /images/generations route.
2910 - if (!empty($this->options['custom_provider_for_images']) && $this->options['custom_provider_for_images'] === 'on') {
2911 - $image_response = $this->mxchat_generate_custom_image($prompt);
2912 - } else {
2913 - // Use the existing OpenAI API key
2914 - $openai_api_key = sanitize_text_field($this->options['api_key']);
2915 - // Call OpenAI GPT Image to generate an image
2916 - $image_response = $this->mxchat_generate_openai_image($prompt, $openai_api_key);
2917 - }
2918 1623
1624 + // Use the existing OpenAI API key
1625 + $openai_api_key = sanitize_text_field($this->options['api_key']);
1626 +
1627 + // Call DALL-E to generate an image
1628 + $image_response = $this->mxchat_generate_dalle_image($prompt, $openai_api_key);
1629 +
2919 1630 // Check if the response contains an image URL
2920 1631 if (isset($image_response['imageUrl'])) {
2921 1632 $image_url = esc_url_raw($image_response['imageUrl']);
2922 1633
@@ -2959,125 +1670,24 @@
2959 1670 // Return the response directly instead of relying on the property
2960 1671 return $this->fallbackResponse;
2961 1672 }
2962 1673 }
2963 -
2964 -public function mxchat_generate_gemini_image($message, $user_id, $session_id) {
2965 - $prompt = esc_html__('Create an image of ', 'mxchat') . sanitize_text_field($message);
2966 -
2967 - $gemini_api_key = sanitize_text_field($this->options['gemini_api_key'] ?? '');
2968 - if (empty($gemini_api_key)) {
2969 - $response_text = esc_html__("Gemini API key is not configured.", 'mxchat');
2970 - $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2971 - return ['text' => $response_text, 'html' => '', 'images' => []];
2972 - }
2973 -
2974 - $image_response = $this->mxchat_generate_imagen_image($prompt, $gemini_api_key);
2975 -
2976 - if (isset($image_response['imageUrl'])) {
2977 - $image_url = esc_url_raw($image_response['imageUrl']);
2978 -
2979 - $response_html = '<img src="' . esc_url($image_url) . '" alt="' . esc_attr__('Generated Image', 'mxchat') . '" class="mxchat-generated-image" />';
2980 - $response_text = esc_html__('Here is the image I generated:', 'mxchat');
2981 -
2982 - $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2983 - $this->mxchat_save_chat_message($session_id, 'bot', $response_html);
2984 -
2985 - $this->fallbackResponse = [
2986 - 'text' => $response_text,
2987 - 'html' => $response_html,
2988 - 'images' => [$image_url]
2989 - ];
2990 -
2991 - return $this->fallbackResponse;
2992 - } else {
2993 - $response_text = esc_html__("I'm sorry, but I couldn't generate an image based on your request.", 'mxchat');
2994 -
2995 - $this->mxchat_save_chat_message($session_id, 'bot', $response_text);
2996 -
2997 - $this->fallbackResponse = [
2998 - 'text' => $response_text,
2999 - 'html' => '',
3000 - 'images' => []
3001 - ];
3002 -
3003 - return $this->fallbackResponse;
3004 - }
3005 -}
3006 -
3007 -private function mxchat_save_generated_image($base64_data, $mime_type = 'image/png', $prefix = 'mxchat-generated') {
3008 - // Map the real mime type to a matching file extension so the saved file's
3009 - // extension always agrees with its bytes. A mismatch (e.g. Imagen returning
3010 - // webp bytes that were written into a ".png" file) makes the browser refuse
3011 - // to render the image even though the file saved successfully and the bot
3012 - // reported success — that was the Gemini/Imagen "image never renders" bug.
3013 - // OpenAI + custom-provider paths pass 'image/png' explicitly, so they are
3014 - // unaffected; this only matters for providers that return another type.
3015 - $mime_to_ext = [
3016 - 'image/jpeg' => 'jpg',
3017 - 'image/jpg' => 'jpg',
3018 - 'image/png' => 'png',
3019 - 'image/webp' => 'webp',
3020 - 'image/gif' => 'gif',
3021 - ];
3022 - $mime_type = strtolower(trim((string) $mime_type));
3023 - if (isset($mime_to_ext[$mime_type])) {
3024 - $extension = $mime_to_ext[$mime_type];
3025 - } else {
3026 - // Unknown/unsupported type: fall back to png and normalize the stored
3027 - // mime so the attachment record and the file extension stay consistent.
3028 - $extension = 'png';
3029 - $mime_type = 'image/png';
3030 - }
3031 - $filename = sanitize_file_name($prefix . '-' . wp_generate_uuid4() . '.' . $extension);
3032 - $decoded = base64_decode($base64_data);
3033 -
3034 - if ($decoded === false) {
3035 - return new \WP_Error('decode_failed', esc_html__('Failed to decode image data.', 'mxchat'));
3036 - }
3037 -
3038 - $upload = wp_upload_bits($filename, null, $decoded);
3039 -
3040 - if (!empty($upload['error'])) {
3041 - return new \WP_Error('upload_failed', $upload['error']);
3042 - }
3043 -
3044 - $attach_id = wp_insert_attachment([
3045 - 'post_mime_type' => $mime_type,
3046 - 'post_title' => $prefix,
3047 - 'post_content' => '',
3048 - 'post_status' => 'inherit',
3049 - ], $upload['file']);
3050 -
3051 - if (is_wp_error($attach_id)) {
3052 - return $attach_id;
3053 - }
3054 -
3055 - require_once ABSPATH . 'wp-admin/includes/image.php';
3056 - $metadata = wp_generate_attachment_metadata($attach_id, $upload['file']);
3057 - wp_update_attachment_metadata($attach_id, $metadata);
3058 -
3059 - return esc_url_raw(wp_get_attachment_url($attach_id));
3060 -}
3061 -
3062 -private function mxchat_generate_openai_image($prompt, $api_key, $model = 'gpt-image-1', $timeout = 60) {
1674 +private function mxchat_generate_dalle_image($prompt, $api_key, $model = 'dall-e-3', $timeout = 60) {
3063 1675 $api_url = 'https://api.openai.com/v1/images/generations';
3064 1676 $body = json_encode([
3065 - 'prompt' => sanitize_text_field($prompt),
3066 - 'n' => 1,
3067 - 'size' => '1024x1024',
3068 - 'quality' => 'medium',
3069 - 'output_format' => 'png',
3070 - 'model' => sanitize_text_field($model),
1677 + 'prompt' => sanitize_text_field($prompt),
1678 + 'n' => 1,
1679 + 'size' => '1024x1024',
1680 + 'model' => sanitize_text_field($model),
3071 1681 ]);
3072 1682
3073 1683 $args = [
3074 - 'body' => $body,
1684 + 'body' => $body,
3075 1685 'headers' => [
3076 - 'Content-Type' => 'application/json',
1686 + 'Content-Type' => 'application/json',
3077 1687 'Authorization' => 'Bearer ' . sanitize_text_field($api_key),
3078 1688 ],
3079 - 'method' => 'POST',
1689 + 'method' => 'POST',
3080 1690 'timeout' => absint($timeout),
3081 1691 ];
3082 1692
3083 1693 $response = wp_remote_post($api_url, $args);
@@ -3082,114 +1692,23 @@
3082 1692
3083 1693 $response = wp_remote_post($api_url, $args);
3084 1694
3085 1695 if (is_wp_error($response)) {
1696 + //error_log("DALL-E request failed: " . $response->get_error_message());
3086 1697 return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3087 1698 }
3088 1699
3089 1700 $response_body = json_decode(wp_remote_retrieve_body($response), true);
3090 1701
3091 - $b64 = $response_body['data'][0]['b64_json'] ?? $response_body['data'][0]['b64'] ?? null;
3092 - if ($b64) {
3093 - $saved_url = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-openai');
3094 - if (is_wp_error($saved_url)) {
3095 - return ['error' => $saved_url->get_error_message()];
3096 - }
3097 - return ['imageUrl' => $saved_url];
1702 + if (isset($response_body['data'][0]['url'])) {
1703 + return ['imageUrl' => esc_url_raw($response_body['data'][0]['url'])];
3098 1704 } else {
1705 + //error_log("DALL-E response error: " . wp_remote_retrieve_body($response));
3099 1706 return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3100 1707 }
3101 1708 }
3102 1709
3103 1710 /**
3104 - * Generate an image via a Custom (OpenAI-compatible) provider's /images/generations route.
3105 - * Only called when the opt-in 'custom_provider_for_images' setting is on.
3106 - */
3107 -private function mxchat_generate_custom_image($prompt, $timeout = 90) {
3108 - $cfg = $this->mxchat_resolve_custom_provider();
3109 - if (empty($cfg['base_url'])) {
3110 - return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat')];
3111 - }
3112 - $url = $cfg['base_url'] . '/images/generations';
3113 - if (!empty($cfg['api_version'])) {
3114 - $url .= (strpos($url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
3115 - }
3116 - $body = wp_json_encode([
3117 - 'prompt' => sanitize_text_field($prompt),
3118 - 'n' => 1,
3119 - 'size' => '1024x1024',
3120 - 'model' => $cfg['model'],
3121 - ]);
3122 - $response = wp_remote_post($url, [
3123 - 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3124 - 'body' => $body,
3125 - 'method' => 'POST',
3126 - 'timeout' => absint($timeout),
3127 - ]);
3128 - if (is_wp_error($response)) {
3129 - return ['error' => esc_html__('Error generating image (custom provider): ', 'mxchat') . $response->get_error_message()];
3130 - }
3131 - $resp = json_decode(wp_remote_retrieve_body($response), true);
3132 - // Try b64 first (matches OpenAI shape), then url-based fallback.
3133 - $b64 = $resp['data'][0]['b64_json'] ?? $resp['data'][0]['b64'] ?? null;
3134 - if ($b64) {
3135 - $saved = $this->mxchat_save_generated_image($b64, 'image/png', 'mxchat-custom');
3136 - if (is_wp_error($saved)) {
3137 - return ['error' => $saved->get_error_message()];
3138 - }
3139 - return ['imageUrl' => $saved];
3140 - }
3141 - $remote_url = $resp['data'][0]['url'] ?? null;
3142 - if ($remote_url) {
3143 - return ['imageUrl' => esc_url_raw($remote_url)];
3144 - }
3145 - $err_msg = $this->extract_provider_error($resp, esc_html__('Custom provider did not return an image.', 'mxchat'));
3146 - return ['error' => esc_html($err_msg)];
3147 -}
3148 -
3149 -private function mxchat_generate_imagen_image($prompt, $api_key, $timeout = 60) {
3150 - $api_url = 'https://generativelanguage.googleapis.com/v1beta/models/imagen-4.0-generate-001:predict';
3151 -
3152 - $body = json_encode([
3153 - 'instances' => [['prompt' => sanitize_text_field($prompt)]],
3154 - 'parameters' => [
3155 - 'sampleCount' => 1,
3156 - 'aspectRatio' => '1:1',
3157 - ],
3158 - ]);
3159 -
3160 - $args = [
3161 - 'body' => $body,
3162 - 'headers' => [
3163 - 'Content-Type' => 'application/json',
3164 - 'x-goog-api-key' => sanitize_text_field($api_key),
3165 - ],
3166 - 'method' => 'POST',
3167 - 'timeout' => absint($timeout),
3168 - ];
3169 -
3170 - $response = wp_remote_post($api_url, $args);
3171 -
3172 - if (is_wp_error($response)) {
3173 - return ['error' => esc_html__('Error generating image: ', 'mxchat') . $response->get_error_message()];
3174 - }
3175 -
3176 - $response_body = json_decode(wp_remote_retrieve_body($response), true);
3177 -
3178 - $b64 = $response_body['predictions'][0]['bytesBase64Encoded'] ?? $response_body['predictions'][0]['imageBytes'] ?? null;
3179 - if ($b64) {
3180 - $mime = $response_body['predictions'][0]['mimeType'] ?? 'image/png';
3181 - $saved_url = $this->mxchat_save_generated_image($b64, $mime, 'mxchat-gemini');
3182 - if (is_wp_error($saved_url)) {
3183 - return ['error' => $saved_url->get_error_message()];
3184 - }
3185 - return ['imageUrl' => $saved_url];
3186 - } else {
3187 - return ['error' => esc_html__('Failed to generate image.', 'mxchat')];
3188 - }
3189 -}
3190 -
3191 -/**
3192 1711 * Handle web search requests.
3193 1712 *
3194 1713 * Sends the refined search query to the Brave Search API and uses the
3195 1714 * results to generate a conversational response with the AI model.
@@ -3237,10 +1756,10 @@
3237 1756 $transient_key = 'mxchat_search_' . md5($refined_search_query);
3238 1757 $results = get_transient($transient_key);
3239 1758
3240 1759 if (false === $results) {
3241 - // SECURITY FIX: Changed to wp_safe_remote_get
3242 - $response = wp_safe_remote_get(
1760 + // Fetch new results from the Brave Search API
1761 + $response = wp_remote_get(
3243 1762 $api_url,
3244 1763 array(
3245 1764 'headers' => array(
3246 1765 'Accept' => 'application/json',
@@ -3379,10 +1898,9 @@
3379 1898 ],
3380 1899 'timeout' => 10,
3381 1900 ];
3382 1901
3383 - // SECURITY FIX: Changed to wp_safe_remote_get
3384 - $response = wp_safe_remote_get($api_url, $args);
1902 + $response = wp_remote_get($api_url, $args);
3385 1903
3386 1904 if (is_wp_error($response)) {
3387 1905 return array(
3388 1906 'text' => __("I'm sorry, I couldn't retrieve any images based on your request.", 'mxchat'),
@@ -3452,22 +1970,17 @@
3452 1970 * @return string The refined search query
3453 1971 */
3454 1972 public function mxchat_interpret_search_query($user_query) {
3455 1973 $system_prompt = esc_html__("Interpret the user's request to provide only the essential keywords or phrases for image searching. Remove conversational language, politeness, or extra context. Return a concise search query that doesn't lose any of the original meaning.", 'mxchat');
3456 -
1974 +
3457 1975 // Get options and determine the selected model
3458 1976 $options = $this->options ?? get_option('mxchat_options');
3459 - $selected_model = isset($options['model']) ? $options['model'] : 'gpt-5.6-sol';
3460 -
3461 - // Custom (OpenAI-compatible) provider routes by model id, not prefix.
3462 - if ($selected_model === 'custom-provider') {
3463 - return $this->interpret_query_with_custom($user_query, $system_prompt);
3464 - }
3465 -
1977 + $selected_model = isset($options['model']) ? $options['model'] : 'gpt-4o';
1978 +
3466 1979 // Extract model prefix to determine the provider
3467 1980 $model_parts = explode('-', $selected_model);
3468 1981 $provider = strtolower($model_parts[0]);
3469 -
1982 +
3470 1983 // Determine which API key to use based on the provider
3471 1984 switch ($provider) {
3472 1985 case 'gemini':
3473 1986 $api_key = isset($options['gemini_api_key']) ? sanitize_text_field($options['gemini_api_key']) : '';
@@ -3508,95 +2021,26 @@
3508 2021 }
3509 2022 }
3510 2023
3511 2024 /**
3512 - * Interpret query against the configured Custom (OpenAI-compatible) provider.
3513 - * Uses the same base URL + auth scheme as the chat dispatcher.
3514 - */
3515 -private function interpret_query_with_custom($user_query, $system_prompt) {
3516 - $cfg = $this->mxchat_resolve_custom_provider();
3517 - if (empty($cfg['base_url'])) {
3518 - return sanitize_text_field($user_query);
3519 - }
3520 - // plan-mxchat-20260715-7124f4: a custom OpenAI-compatible endpoint pointed at
3521 - // a gpt-5-class model rejects temperature!=1 and the legacy max_tokens key.
3522 - // Byte-identical for ordinary custom models (temperature kept, max_tokens
3523 - // used); only gpt-5-class custom models change (best-effort — custom
3524 - // endpoints vary).
3525 - $token_key = $this->mxchat_openai_token_param_for($cfg['model']);
3526 - $payload = [
3527 - 'model' => $cfg['model'],
3528 - 'messages' => [
3529 - ['role' => 'system', 'content' => $system_prompt],
3530 - ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3531 - ],
3532 - $token_key => 20,
3533 - ];
3534 - if ($this->mxchat_openai_supports_temperature_for($cfg['model'])) {
3535 - $payload['temperature'] = 0.2;
3536 - }
3537 - $args = [
3538 - 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
3539 - 'body' => wp_json_encode($payload),
3540 - 'method' => 'POST',
3541 - 'timeout' => 15,
3542 - ];
3543 - $response = wp_remote_post($cfg['chat_url'], $args);
3544 - if (is_wp_error($response)) {
3545 - return sanitize_text_field($user_query);
3546 - }
3547 - $body = json_decode(wp_remote_retrieve_body($response), true);
3548 - return isset($body['choices'][0]['message']['content'])
3549 - ? sanitize_text_field(trim($body['choices'][0]['message']['content']))
3550 - : sanitize_text_field($user_query);
3551 -}
3552 -
3553 -/**
3554 - * Convert the colon-style header list returned by mxchat_resolve_custom_provider
3555 - * into the assoc-array form wp_remote_post expects.
3556 - */
3557 -private function mxchat_custom_provider_assoc_headers($cfg) {
3558 - $headers = ['Content-Type' => 'application/json'];
3559 - if (!empty($cfg['api_key'])) {
3560 - if (($cfg['auth_scheme'] ?? 'bearer') === 'api-key') {
3561 - $headers['api-key'] = $cfg['api_key'];
3562 - } else {
3563 - $headers['Authorization'] = 'Bearer ' . $cfg['api_key'];
3564 - }
3565 - }
3566 - return $headers;
3567 -}
3568 -
3569 -/**
3570 2025 * Interpret query using OpenAI models
3571 2026 */
3572 -private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-5.6-sol') {
2027 +private function interpret_query_with_openai($user_query, $system_prompt, $api_key, $model = 'gpt-4o') {
3573 2028 $url = 'https://api.openai.com/v1/chat/completions';
3574 - // plan-mxchat-20260715-7124f4: the default chat model is a gpt-5-family id
3575 - // and every gpt-5* rejects both a non-default temperature and the legacy
3576 - // max_tokens key (400). This call swallowed the 400 and silently degraded to
3577 - // the raw query on every gpt-5 install, quietly disabling product/image
3578 - // search-query interpretation. Derive capability from the core catalog
3579 - // (dcb71c) so this tracks future model adds; strpos fallback for a
3580 - // partial-upgrade window where the catalog method isn't loaded.
3581 - $token_key = $this->mxchat_openai_token_param_for($model);
3582 - $payload = [
3583 - 'model' => $model,
3584 - 'messages' => [
3585 - ['role' => 'system', 'content' => $system_prompt],
3586 - ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3587 - ],
3588 - $token_key => 20,
3589 - ];
3590 - if ($this->mxchat_openai_supports_temperature_for($model)) {
3591 - $payload['temperature'] = 0.2;
3592 - }
3593 2029 $args = [
3594 2030 'headers' => [
3595 2031 'Authorization' => 'Bearer ' . $api_key,
3596 2032 'Content-Type' => 'application/json',
3597 2033 ],
3598 - 'body' => wp_json_encode($payload),
2034 + 'body' => wp_json_encode([
2035 + 'model' => $model,
2036 + 'messages' => [
2037 + ['role' => 'system', 'content' => $system_prompt],
2038 + ['role' => 'user', 'content' => sanitize_text_field($user_query)],
2039 + ],
2040 + 'temperature' => 0.2,
2041 + 'max_tokens' => 20,
2042 + ]),
3599 2043 'method' => 'POST',
3600 2044 'timeout' => 15,
3601 2045 ];
3602 2046
@@ -3611,124 +2055,13 @@
3611 2055 : sanitize_text_field($user_query);
3612 2056 }
3613 2057
3614 2058 /**
3615 - * Anthropic removed temperature/top_p/top_k starting with Opus 4.7 (the API
3616 - * returns 400 if sent) — add new flagship model ids here. (We don't send
3617 - * top_p/top_k in any Claude body, so the list only needs to gate temperature
3618 - * stripping. We never send a `thinking` param either, which is required for
3619 - * claude-fable-5: it rejects an explicit thinking "disabled" — omit only.)
3620 - */
3621 -private function mxchat_claude_omits_temperature($model) {
3622 - // plan-mxchat-20260714-dcb71c: derive from the core model catalog (single
3623 - // source of truth). Every caller here passes a Claude model, so
3624 - // !supports_temperature() reproduces the old 4-id in_array() result exactly.
3625 - // Frozen list kept as fallback for a partial-upgrade window where the
3626 - // catalog method isn't loaded.
3627 - if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3628 - return !MxChat_Model_Catalog::supports_temperature($model);
3629 - }
3630 - $no_temp = array('claude-opus-5', 'claude-opus-4-7', 'claude-opus-4-8', 'claude-fable-5', 'claude-sonnet-5');
3631 - return in_array($model, $no_temp, true);
3632 -}
3633 -
3634 -/**
3635 - * plan-mxchat-20260715-7124f4: OpenAI completion-token key for this model,
3636 - * sourced from the core catalog (gpt-5* → max_completion_tokens; else
3637 - * max_tokens). strpos fallback for a partial-upgrade window where the catalog
3638 - * method isn't loaded.
3639 - *
3640 - * @param string $model OpenAI(-compatible) model id.
3641 - * @return string 'max_completion_tokens' | 'max_tokens'
3642 - */
3643 -private function mxchat_openai_token_param_for($model) {
3644 - if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'openai_token_param')) {
3645 - return MxChat_Model_Catalog::openai_token_param($model);
3646 - }
3647 - return strpos((string) $model, 'gpt-5') === 0 ? 'max_completion_tokens' : 'max_tokens';
3648 -}
3649 -
3650 -/**
3651 - * plan-mxchat-20260715-7124f4: whether a NON-default temperature may be sent to
3652 - * this OpenAI(-compatible) model. gpt-5* accept only the default (1) — sending
3653 - * any other value 400s. Sourced from the core catalog; strpos fallback for a
3654 - * partial-upgrade window.
3655 - *
3656 - * @param string $model OpenAI(-compatible) model id.
3657 - * @return bool
3658 - */
3659 -private function mxchat_openai_supports_temperature_for($model) {
3660 - if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'supports_temperature')) {
3661 - return MxChat_Model_Catalog::supports_temperature($model);
3662 - }
3663 - return strpos((string) $model, 'gpt-5') !== 0;
3664 -}
3665 -
3666 -/**
3667 - * plan-mxchat-20260714-dcb71c: per-surface reasoning_effort, sourced from the
3668 - * core model catalog so a model add propagates automatically. The fallback is
3669 - * the frozen pre-dcb71c inline ladder, used only if the catalog method is
3670 - * unavailable (a partial-upgrade window). Byte-identical to the old inline
3671 - * blocks by construction — proven by the dcb71c equivalence harness.
3672 - *
3673 - * @param string $model Chat model id.
3674 - * @param string $context 'chat' | 'websearch'.
3675 - * @return string|null Effort to send, or null to omit the param.
3676 - */
3677 -private function mxchat_reasoning_effort_for($model, $context) {
3678 - if (class_exists('MxChat_Model_Catalog') && method_exists('MxChat_Model_Catalog', 'reasoning_effort_for')) {
3679 - return MxChat_Model_Catalog::reasoning_effort_for($model, $context);
3680 - }
3681 - return $this->mxchat_reasoning_effort_fallback($model, $context);
3682 -}
3683 -
3684 -private function mxchat_reasoning_effort_fallback($model, $context) {
3685 - if (strpos($model, 'gpt-5') !== 0) {
3686 - return null;
3687 - }
3688 - if ($context === 'websearch') {
3689 - $no_reasoning_web = array('gpt-5.2', 'gpt-5.3-chat-latest', 'gpt-5.4-mini', 'gpt-5.4-nano');
3690 - if (in_array($model, $no_reasoning_web, true)) return null;
3691 - if ($model === 'gpt-5.1-2025-11-13') return 'low';
3692 - if ($model === 'gpt-5.5') return 'low';
3693 - if ($model === 'gpt-5.4') return 'low';
3694 - if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3695 - return null;
3696 - }
3697 - // 'chat'
3698 - // gpt-5.1/5.3-chat-latest stay listed after their 2026-08-10 retirement:
3699 - // unmigrated bot-level / add-on-saved ids must keep routing correctly
3700 - // until every surface is swept (plan e46b8f).
3701 - $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');
3702 - if (in_array($model, $no_reasoning_models, true)) return null;
3703 - if ($model === 'gpt-5.1-2025-11-13') return 'low';
3704 - if ($model === 'gpt-5.5') return 'none';
3705 - if ($model === 'gpt-5.4') return 'none';
3706 - if (in_array($model, array('gpt-5.6-sol', 'gpt-5.6-terra', 'gpt-5.6-luna'), true)) return 'low';
3707 - return 'minimal';
3708 -}
3709 -
3710 -/**
3711 2059 * Interpret query using Claude models
3712 2060 */
3713 2061 private function interpret_query_with_claude($user_query, $system_prompt, $api_key, $model) {
3714 - // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
3715 - // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
3716 - if ($model === 'claude-opus-4-20250514') { $model = 'claude-opus-4-8'; }
3717 - elseif ($model === 'claude-sonnet-4-20250514') { $model = 'claude-sonnet-4-6'; }
3718 2062 $url = 'https://api.anthropic.com/v1/messages';
3719 -
3720 - $payload = [
3721 - 'model' => $model,
3722 - 'system' => $system_prompt,
3723 - 'messages' => [
3724 - ['role' => 'user', 'content' => sanitize_text_field($user_query)]
3725 - ],
3726 - 'max_tokens' => 20,
3727 - 'temperature' => 0.2,
3728 - ];
3729 - if ($this->mxchat_claude_omits_temperature($model)) { unset($payload['temperature']); }
3730 -
2063 +
3731 2064 $args = [
3732 2065 'headers' => [
3733 2066 'Content-Type' => 'application/json',
3734 2067 'x-api-key' => $api_key,
@@ -3733,9 +2066,17 @@
3733 2066 'Content-Type' => 'application/json',
3734 2067 'x-api-key' => $api_key,
3735 2068 'anthropic-version' => '2023-06-01',
3736 2069 ],
3737 - 'body' => wp_json_encode($payload),
2070 + 'body' => wp_json_encode([
2071 + 'model' => $model,
2072 + 'system' => $system_prompt,
2073 + 'messages' => [
2074 + ['role' => 'user', 'content' => sanitize_text_field($user_query)]
2075 + ],
2076 + 'max_tokens' => 20,
2077 + 'temperature' => 0.2,
2078 + ]),
3738 2079 'method' => 'POST',
3739 2080 'timeout' => 15,
3740 2081 ];
3741 2082
@@ -3744,16 +2085,12 @@
3744 2085 return sanitize_text_field($user_query);
3745 2086 }
3746 2087
3747 2088 $body = json_decode(wp_remote_retrieve_body($response), true);
3748 - // claude-fable-5 prepends a thinking block to content — take the first
3749 - // TEXT block, not content[0].
3750 - foreach ((array) ($body['content'] ?? array()) as $block) {
3751 - if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
3752 - return sanitize_text_field(trim($block['text']));
3753 - }
2089 + if (!empty($body['content'][0]['text'])) {
2090 + return sanitize_text_field(trim($body['content'][0]['text']));
3754 2091 }
3755 -
2092 +
3756 2093 return sanitize_text_field($user_query);
3757 2094 }
3758 2095
3759 2096 /**
@@ -3759,16 +2096,13 @@
3759 2096 /**
3760 2097 * Interpret query using Gemini models
3761 2098 */
3762 2099 private function interpret_query_with_gemini($user_query, $system_prompt, $api_key, $model) {
3763 - if ($model === 'gemini-3-pro-preview') {
3764 - $model = 'gemini-3.1-pro-preview';
3765 - }
3766 - // Use v1beta for preview models, v1 for stable models
3767 - $api_version = (strpos($model, 'preview') !== false || strpos($model, 'exp') !== false) ? 'v1beta' : 'v1';
3768 -
3769 - $url = "https://generativelanguage.googleapis.com/{$api_version}/models/{$model}:generateContent?key=" . urlencode($api_key);
2100 + // Strip "gemini-" prefix for the API
2101 + $model_version = str_replace('gemini-', '', $model);
3770 2102
2103 + $url = "https://generativelanguage.googleapis.com/v1/models/$model_version:generateContent?key=" . urlencode($api_key);
2104 +
3771 2105 $args = [
3772 2106 'headers' => [
3773 2107 'Content-Type' => 'application/json',
3774 2108 ],
@@ -3844,9 +2178,9 @@
3844 2178 * Interpret query using DeepSeek models
3845 2179 */
3846 2180 private function interpret_query_with_deepseek($user_query, $system_prompt, $api_key, $model) {
3847 2181 $url = 'https://api.deepseek.com/v1/chat/completions';
3848 -
2182 +
3849 2183 $args = [
3850 2184 'headers' => [
3851 2185 'Content-Type' => 'application/json',
3852 2186 'Authorization' => 'Bearer ' . $api_key,
@@ -3858,12 +2192,8 @@
3858 2192 ['role' => 'user', 'content' => sanitize_text_field($user_query)],
3859 2193 ],
3860 2194 'temperature' => 0.2,
3861 2195 'max_tokens' => 20,
3862 - // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
3863 - // reasoning burns the 20-token budget); keep the legacy
3864 - // deepseek-chat semantics = non-thinking.
3865 - 'thinking' => ['type' => 'disabled'],
3866 2196 ]),
3867 2197 'method' => 'POST',
3868 2198 'timeout' => 15,
3869 2199 ];
@@ -3968,9 +2298,9 @@
3968 2298 }
3969 2299
3970 2300
3971 2301 /**
3972 - * Enhanced fetch_and_split_pdf_pages with SSRF protection
2302 + * Enhanced fetch_and_split_pdf_pages with detailed debugging
3973 2303 */
3974 2304 private function fetch_and_split_pdf_pages($pdf_source, $max_pages) {
3975 2305 // CLEAR DEBUG LOGGING
3976 2306 //error_log("=== MXCHAT PDF PROCESSING START ===");
@@ -4025,25 +2355,12 @@
4025 2355 // (I'll include the key parts with debug logging)
4026 2356
4027 2357 if (filter_var($pdf_source, FILTER_VALIDATE_URL)) {
4028 2358 //error_log("Downloading PDF from URL...");
4029 -
4030 - // SECURITY FIX: Validate URL before processing
4031 - if (!$this->mxchat_is_safe_pdf_url($pdf_source)) {
4032 - //error_log("❌ SECURITY: Blocked unsafe PDF URL");
4033 - return false;
4034 - }
4035 -
4036 2359 $temp_file = wp_tempnam($pdf_source);
4037 -
4038 - // SECURITY FIX: Changed from wp_remote_get to wp_safe_remote_get
4039 - // Route through the shared MXChat crawler identity (plan bae78f/b6d93c) so
4040 - // every remote-content fetch presents one honest, versioned, filterable,
4041 - // allowlistable User-Agent. function_exists guard keeps the front-end/nopriv
4042 - // path safe if the helper (in the always-loaded main file) is ever unavailable.
4043 - $response = wp_safe_remote_get($pdf_source, [
2360 + $response = wp_remote_get($pdf_source, [
4044 2361 'timeout' => 60,
4045 - 'headers' => ['User-Agent' => function_exists('mxchat_ingest_user_agent') ? mxchat_ingest_user_agent() : 'MxChat PDF Processor']
2362 + 'headers' => ['User-Agent' => 'MxChat PDF Processor']
4046 2363 ]);
4047 2364
4048 2365 if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) {
4049 2366 $error_message = is_wp_error($response) ? $response->get_error_message() : 'HTTP ' . wp_remote_retrieve_response_code($response);
@@ -4050,14 +2367,9 @@
4050 2367 //error_log("❌ BASIC PROCESSING: Failed to download PDF: " . $error_message);
4051 2368 return false;
4052 2369 }
4053 2370
4054 - global $wp_filesystem;
4055 - if (empty($wp_filesystem)) {
4056 - require_once ABSPATH . 'wp-admin/includes/file.php';
4057 - WP_Filesystem();
4058 - }
4059 - $wp_filesystem->put_contents($temp_file, wp_remote_retrieve_body($response), FS_CHMOD_FILE);
2371 + file_put_contents($temp_file, wp_remote_retrieve_body($response));
4060 2372 //error_log("✅ PDF downloaded successfully");
4061 2373 } else {
4062 2374 $temp_file = $pdf_source;
4063 2375 //error_log("Using local PDF file: " . $temp_file);
@@ -4064,9 +2376,8 @@
4064 2376 }
4065 2377
4066 2378 // Parse PDF
4067 2379 //error_log("Parsing PDF with basic parser...");
4068 - mxchat_load_pdf_parser();
4069 2380 $parser = new \Smalot\PdfParser\Parser();
4070 2381 $pdf = $parser->parseFile($temp_file);
4071 2382 $pages = $pdf->getPages();
4072 2383
@@ -4129,33 +2440,8 @@
4129 2440 return false;
4130 2441 }
4131 2442 }
4132 2443
4133 -
4134 -/**
4135 - * Validate PDF URL for security
4136 - * Prevents SSRF attacks by blocking dangerous URLs
4137 - */
4138 -
4139 -private function mxchat_is_safe_pdf_url($url) {
4140 - // Use WordPress core function for comprehensive validation
4141 - // This blocks localhost, private IPs, and reserved IP ranges
4142 - $validated_url = wp_http_validate_url($url);
4143 -
4144 - if ($validated_url === false) {
4145 - return false;
4146 - }
4147 -
4148 - // Additional check: only allow HTTP/HTTPS schemes
4149 - $parsed = parse_url($url);
4150 - if (!isset($parsed['scheme']) || !in_array($parsed['scheme'], ['http', 'https'], true)) {
4151 - return false;
4152 - }
4153 -
4154 - return true;
4155 -}
4156 -
4157 -
4158 2444 private function mxchat_clean_text($text) {
4159 2445 // Remove excessive whitespace
4160 2446 $text = preg_replace('/\s+/', ' ', $text);
4161 2447
@@ -4194,14 +2480,11 @@
4194 2480 }
4195 2481
4196 2482 return [];
4197 2483 }
4198 -
4199 -
2484 +// Add this to your class
4200 2485 public function handle_pdf_upload() {
4201 - if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4202 - wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4203 - }
2486 + check_ajax_referer('mxchat_chat_nonce', 'nonce');
4204 2487
4205 2488 if (!isset($_FILES['pdf_file']) || !isset($_POST['session_id'])) {
4206 2489 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
4207 2490 return;
@@ -4206,29 +2489,12 @@
4206 2489 wp_send_json_error(esc_html__('Missing required parameters.', 'mxchat'));
4207 2490 return;
4208 2491 }
4209 2492
4210 - // SECURITY FIX: Check if PDF uploads are enabled in settings
4211 - $options = get_option('mxchat_options', array());
4212 - $show_pdf_button = isset($options['show_pdf_upload_button']) ? $options['show_pdf_upload_button'] : 'on';
4213 -
4214 - if ($show_pdf_button !== 'on') {
4215 - wp_send_json_error(esc_html__('PDF uploads are currently disabled.', 'mxchat'));
4216 - return;
4217 - }
4218 -
4219 2493 $file = $_FILES['pdf_file'];
4220 - $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
2494 + $session_id = sanitize_text_field($_POST['session_id']);
4221 2495 $original_filename = sanitize_text_field($file['name']);
4222 2496
4223 - // Update session owner if it changed (e.g. IP changed due to network switch)
4224 - $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
4225 - $session_owner = get_option("mxchat_session_owner_{$session_id}");
4226 -
4227 - if (!$session_owner || $session_owner !== $current_user_identifier) {
4228 - update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
4229 - }
4230 -
4231 2497 $file_type = wp_check_filetype($file['name'], ['pdf' => 'application/pdf']);
4232 2498 if ($file_type['type'] !== 'application/pdf') {
4233 2499 wp_send_json_error(esc_html__('Invalid file type. Only PDF files are allowed.', 'mxchat'));
4234 2500 return;
@@ -4234,12 +2500,9 @@
4234 2500 return;
4235 2501 }
4236 2502
4237 2503 $upload_dir = wp_upload_dir();
4238 -
4239 - // SECURITY FIX: Generate random filename without exposing session_id
4240 - $random_string = wp_generate_password(20, false, false); // 20 char alphanumeric string
4241 - $pdf_filename = 'mxchat_' . $random_string . '_' . time() . '.pdf';
2504 + $pdf_filename = 'mxchat_' . $session_id . '_' . time() . '.pdf';
4242 2505 $pdf_path = $upload_dir['path'] . '/' . $pdf_filename;
4243 2506
4244 2507 if (!move_uploaded_file($file['tmp_name'], $pdf_path)) {
4245 2508 wp_send_json_error(esc_html__('Failed to upload file.', 'mxchat'));
@@ -4270,9 +2533,8 @@
4270 2533 return;
4271 2534 }
4272 2535
4273 2536 if (!empty($embeddings)) {
4274 - // Store the mapping between session and the random filename
4275 2537 set_transient('mxchat_pdf_url_' . $session_id, $pdf_path, HOUR_IN_SECONDS);
4276 2538 set_transient('mxchat_pdf_filename_' . $session_id, $original_filename, HOUR_IN_SECONDS);
4277 2539 set_transient('mxchat_pdf_embeddings_' . $session_id, $embeddings, HOUR_IN_SECONDS);
4278 2540 set_transient('mxchat_include_pdf_in_context_' . $session_id, true, HOUR_IN_SECONDS);
@@ -4293,11 +2555,9 @@
4293 2555 wp_send_json_error($error_message);
4294 2556 return;
4295 2557 }
4296 2558 public function handle_pdf_remove() {
4297 - if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce(wp_unslash((string) $_POST['nonce']))) {
4298 - wp_send_json_error(array('message' => esc_html__('Invalid nonce.', 'mxchat')), 403);
4299 - }
2559 + check_ajax_referer('mxchat_chat_nonce', 'nonce');
4300 2560
4301 2561 if (empty($_POST['session_id'])) {
4302 2562 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4303 2563 wp_die();
@@ -4302,34 +2562,9 @@
4302 2562 wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4303 2563 wp_die();
4304 2564 }
4305 2565
4306 - $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
4307 - if ($session_id === '') {
4308 - wp_send_json_error(esc_html__('Session ID missing.', 'mxchat'));
4309 - wp_die();
4310 - }
4311 -
4312 - // Session-ownership bookkeeping (plan-mxchat-20260731-d42bec).
4313 - //
4314 - // Be clear about what this does and does not do. It mirrors the history
4315 - // endpoint's rule exactly, as directed, INCLUDING its changed-IP tolerance:
4316 - // possession of the session id IS the credential, so a mismatched identifier
4317 - // re-owns the session instead of being refused. That means this does NOT
4318 - // refuse a caller who supplies someone else's session id — it keeps the two
4319 - // endpoints agreeing about who owns a session, and records the owner so a
4320 - // future stricter policy has trustworthy data to enforce against.
4321 - //
4322 - // What actually protects another visitor's upload here is that session ids
4323 - // are 128-bit CSPRNG values (plan-0c17b5) and therefore not guessable. If we
4324 - // ever want a real boundary on this endpoint, it has to be decided for the
4325 - // history endpoint at the same time.
4326 - $current_user_identifier = MxChat_User::mxchat_get_user_identifier();
4327 - $session_owner = get_option("mxchat_session_owner_{$session_id}");
4328 - if (!$session_owner || $session_owner !== $current_user_identifier) {
4329 - update_option("mxchat_session_owner_{$session_id}", $current_user_identifier, 'no');
4330 - }
4331 -
2566 + $session_id = sanitize_text_field($_POST['session_id']);
4332 2567 $pdf_path = get_transient('mxchat_pdf_url_' . $session_id);
4333 2568
4334 2569 if ($pdf_path && file_exists($pdf_path)) {
4335 2570 unlink($pdf_path);
@@ -4343,10 +2578,12 @@
4343 2578 wp_die();
4344 2579 }
4345 2580
4346 2581
2582 +
2583 +
4347 2584 function mxchat_fetch_new_messages() {
4348 - $session_id = MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id']));
2585 + $session_id = sanitize_text_field($_POST['session_id']);
4349 2586 $last_seen_id = sanitize_text_field($_POST['last_seen_id']);
4350 2587 $persistence_enabled = $_POST['persistence_enabled'] === 'true';
4351 2588 $initial_timestamp = isset($_POST['initial_timestamp']) ? intval($_POST['initial_timestamp']) : 0;
4352 2589
@@ -4357,31 +2594,14 @@
4357 2594 }
4358 2595
4359 2596 $history = get_option("mxchat_history_{$session_id}", []);
4360 2597
4361 - //error_log("MxChat WhatsApp DEBUG: Fetch new messages for session {$session_id}");
4362 - //error_log("MxChat WhatsApp DEBUG: last_seen_id = " . var_export($last_seen_id, true));
4363 - //error_log("MxChat WhatsApp DEBUG: History count = " . count($history));
4364 - //error_log("MxChat WhatsApp DEBUG: Full history = " . print_r($history, true));
4365 -
4366 2598 $new_messages = array_filter($history, function ($message) use ($last_seen_id, $persistence_enabled, $initial_timestamp) {
4367 - //error_log("MxChat WhatsApp DEBUG: Checking message - ID: " . ($message['id'] ?? 'NO_ID') . ", Role: " . ($message['role'] ?? 'NO_ROLE'));
4368 -
4369 2599 // If persistence is enabled, show all new messages
4370 2600 if ($persistence_enabled) {
4371 - $has_id = !empty($message['id']);
4372 - $is_agent = $message['role'] === 'agent';
4373 -
4374 - // If last_seen_id is empty, 'NaN', or invalid, show all agent messages
4375 - if (empty($last_seen_id) || $last_seen_id === 'NaN' || $last_seen_id === 'undefined') {
4376 - $is_newer = true;
4377 - } else {
4378 - $is_newer = strcmp($message['id'] ?? '', $last_seen_id) > 0;
4379 - }
4380 -
4381 - //error_log("MxChat WhatsApp DEBUG: has_id={$has_id}, is_newer={$is_newer}, is_agent={$is_agent}");
4382 -
4383 - return $has_id && $is_newer && $is_agent;
2601 + return !empty($message['id']) &&
2602 + strcmp($message['id'], $last_seen_id) > 0 &&
2603 + $message['role'] === 'agent';
4384 2604 }
4385 2605
4386 2606 // If persistence is disabled, only show messages after initial timestamp
4387 2607 return !empty($message['id']) &&
@@ -4388,30 +2608,19 @@
4388 2608 $message['role'] === 'agent' &&
4389 2609 $message['timestamp'] > $initial_timestamp;
4390 2610 });
4391 2611
4392 - //error_log("MxChat WhatsApp DEBUG: Filtered messages count = " . count($new_messages));
2612 + //error_log(esc_html__("New agent messages fetched for session $session_id. Last seen ID: $last_seen_id", 'mxchat'));
4393 2613
4394 - // Include current chat mode so frontend can detect agent→AI transitions
4395 - $chat_mode = get_option("mxchat_mode_{$session_id}", 'ai');
4396 -
4397 2614 wp_send_json_success([
4398 - 'new_messages' => array_values($new_messages),
4399 - 'chat_mode' => $chat_mode
2615 + 'new_messages' => array_values($new_messages)
4400 2616 ]);
4401 2617 wp_die();
4402 2618 }
4403 2619 public function mxchat_live_agent_handover($message, $user_id, $session_id) {
4404 - // First check if live agents are available.
4405 - // Outside the SLACK availability schedule this behaves exactly like the
4406 - // manual toggle being off — same away message, same stay-in-AI-mode path
4407 - // (plans 8ccaa2 + 99d7a4: each channel owns its own schedule). The schedule
4408 - // normally stops the tool being offered at all; this is the backstop for
4409 - // any path that calls the handover directly.
2620 + // First check if live agents are available
4410 2621 $live_agent_available = $this->options['live_agent_status'] ?? 'off';
4411 - $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4412 - || MxChat_Live_Agent_Schedule::is_within_hours('slack');
4413 - if ($live_agent_available !== 'on' || !$within_hours) {
2622 + if ($live_agent_available !== 'on') {
4414 2623 $away_message = $this->options['live_agent_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4415 2624 $this->fallbackResponse = [
4416 2625 'text' => $away_message,
4417 2626 'html' => '',
@@ -4427,9 +2636,9 @@
4427 2636 wp_die();
4428 2637 }
4429 2638
4430 2639 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4431 -
2640 +
4432 2641 if (empty($slack_bot_token)) {
4433 2642 return false;
4434 2643 }
4435 2644
@@ -4434,21 +2643,83 @@
4434 2643 }
4435 2644
4436 2645 // Check if channel already exists for this session
4437 2646 $channel_id = get_option("mxchat_channel_{$session_id}", '');
4438 -
4439 - // Shared-channel mode (plan 9f7756): when a shared handoff channel is
4440 - // configured and this session doesn't already own a per-conversation
4441 - // channel, the handoff posts into the shared channel as a new thread
4442 - // (or into the session's existing thread on a re-handover). Any failure
4443 - // to reach the shared channel falls back to per-conversation creation
4444 - // below, so a misconfigured channel never drops a handoff.
4445 - $shared_channel_setting = trim($this->options['live_agent_shared_channel'] ?? '');
4446 - $shared_thread_ts = get_option("mxchat_thread_{$session_id}", '');
4447 - $use_shared_channel = ($shared_channel_setting !== '' && empty($channel_id));
4448 -
4449 - if (empty($channel_id) && !$use_shared_channel) {
4450 - $channel_id = $this->mxchat_create_conversation_channel($session_id);
2647 +
2648 + if (empty($channel_id)) {
2649 + // Create new channel with session ID as name
2650 + $channel_name = $this->generate_channel_name($session_id);
2651 +
2652 + //error_log("Attempting to create channel: $channel_name");
2653 +
2654 + $response = wp_remote_post('https://slack.com/api/conversations.create', [
2655 + 'headers' => [
2656 + 'Content-Type' => 'application/json',
2657 + 'Authorization' => 'Bearer ' . $slack_bot_token
2658 + ],
2659 + 'body' => json_encode([
2660 + 'name' => $channel_name,
2661 + 'is_private' => false // Public channel - anyone in workspace can join
2662 + ])
2663 + ]);
2664 +
2665 + if (!is_wp_error($response)) {
2666 + $response_body = wp_remote_retrieve_body($response);
2667 + $response_data = json_decode($response_body, true);
2668 +
2669 + //error_log("Channel creation response: " . $response_body);
2670 +
2671 + if (isset($response_data['ok']) && $response_data['ok']) {
2672 + $channel_id = $response_data['channel']['id'];
2673 + $actual_channel_name = $response_data['channel']['name'] ?? 'unknown';
2674 + //error_log("Channel created successfully: ID=$channel_id, Name=$actual_channel_name");
2675 + update_option("mxchat_channel_{$session_id}", $channel_id);
2676 +
2677 + // Auto-invite agents to the channel
2678 + $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
2679 +
2680 + if (!empty($agent_user_ids)) {
2681 + // Parse user IDs (one per line)
2682 + $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
2683 +
2684 + foreach ($user_ids as $user_id_to_invite) {
2685 + //error_log("Inviting user to channel: $user_id_to_invite");
2686 +
2687 + $invite_response = wp_remote_post('https://slack.com/api/conversations.invite', [
2688 + 'headers' => [
2689 + 'Content-Type' => 'application/json',
2690 + 'Authorization' => 'Bearer ' . $slack_bot_token
2691 + ],
2692 + 'body' => json_encode([
2693 + 'channel' => $channel_id,
2694 + 'users' => $user_id_to_invite
2695 + ])
2696 + ]);
2697 +
2698 + if (!is_wp_error($invite_response)) {
2699 + $invite_body = wp_remote_retrieve_body($invite_response);
2700 + $invite_data = json_decode($invite_body, true);
2701 + //error_log("Invite response for $user_id_to_invite: " . $invite_body);
2702 +
2703 + if (isset($invite_data['ok']) && $invite_data['ok']) {
2704 + //error_log("Successfully invited user $user_id_to_invite to channel");
2705 + } else {
2706 + //error_log("Failed to invite user $user_id_to_invite: " . ($invite_data['error'] ?? 'Unknown error'));
2707 + }
2708 + } else {
2709 + //error_log("WP Error inviting user $user_id_to_invite: " . $invite_response->get_error_message());
2710 + }
2711 + }
2712 + } else {
2713 + //error_log("No agent user IDs configured for auto-invite");
2714 + }
2715 + } else {
2716 + //error_log("Channel creation failed: " . ($response_data['error'] ?? 'Unknown error'));
2717 + }
2718 + } else {
2719 + //error_log("WP Error creating channel: " . $response->get_error_message());
2720 + }
2721 +
4451 2722 if (empty($channel_id)) {
4452 2723 return false; // Failed to create channel
4453 2724 }
4454 2725 }
@@ -4472,93 +2743,29 @@
4472 2743
4473 2744 // Send message to channel
4474 2745 $channel_message = "🔔 *New Live Agent Request*\n\n";
4475 2746 $channel_message .= "*Session ID:* `{$session_id}`\n";
4476 - $channel_message .= "*User ID:* `{$user_id}`\n";
4477 -
4478 - // Surface the captured visitor identity so the agent knows who they're talking to —
4479 - // guest User IDs are 0, but the pre-chat gate / login / transcript often has name+email (plan-e2195b).
4480 - $visitor = $this->mxchat_get_visitor_identity($session_id);
4481 - if (!empty($visitor['name']) && !empty($visitor['email'])) {
4482 - $channel_message .= "*Visitor:* {$visitor['name']} <{$visitor['email']}>\n";
4483 - } elseif (!empty($visitor['email'])) {
4484 - $channel_message .= "*Visitor:* <{$visitor['email']}>\n";
4485 - } elseif (!empty($visitor['name'])) {
4486 - $channel_message .= "*Visitor:* {$visitor['name']}\n";
4487 - }
4488 - $channel_message .= "\n";
4489 -
2747 + $channel_message .= "*User ID:* `{$user_id}`\n\n";
2748 +
4490 2749 if (!empty($conversation_context)) {
4491 2750 $channel_message .= $conversation_context;
4492 2751 }
4493 2752
4494 2753 $channel_message .= "*Current Message:*\n{$message}\n\n";
4495 - if ($use_shared_channel) {
4496 - $channel_message .= "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._";
4497 - } else {
4498 - $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
4499 - }
2754 + $channel_message .= "_Reply directly in this channel - all messages will go to the user_";
4500 2755
4501 - if ($use_shared_channel) {
4502 - $posted = $this->mxchat_post_shared_handoff($session_id, $channel_message, $shared_thread_ts);
4503 - if (!$posted) {
4504 - // Shared channel unreachable (wrong name/ID, bot not invited,
4505 - // archived...). Fall back to the per-conversation flow so the
4506 - // visitor still reaches an agent; the settings page surfaces the
4507 - // recorded error to the admin.
4508 - $use_shared_channel = false;
4509 - $channel_id = $this->mxchat_create_conversation_channel($session_id);
4510 - if (empty($channel_id)) {
4511 - return false;
4512 - }
4513 - $channel_message = str_replace(
4514 - "_Reply in this thread - replies here go to the user. `!endchat` in this thread ends the chat._",
4515 - "_Reply directly in this channel - all messages will go to the user_",
4516 - $channel_message
4517 - );
4518 - }
4519 - }
2756 + wp_remote_post('https://slack.com/api/chat.postMessage', [
2757 + 'headers' => [
2758 + 'Content-Type' => 'application/json',
2759 + 'Authorization' => 'Bearer ' . $slack_bot_token
2760 + ],
2761 + 'body' => json_encode([
2762 + 'channel' => $channel_id,
2763 + 'text' => $channel_message,
2764 + 'mrkdwn' => true
2765 + ])
2766 + ]);
4520 2767
4521 - if (!$use_shared_channel) {
4522 - $handoff_post = wp_remote_post('https://slack.com/api/chat.postMessage', [
4523 - 'headers' => [
4524 - 'Content-Type' => 'application/json',
4525 - 'Authorization' => 'Bearer ' . $slack_bot_token
4526 - ],
4527 - 'body' => json_encode([
4528 - 'channel' => $channel_id,
4529 - 'text' => $channel_message,
4530 - 'mrkdwn' => true
4531 - ])
4532 - ]);
4533 - // Re-handover edge (plan 7458a7): the stored mxchat_channel_ may point
4534 - // at a channel archived by the auto-archive toggle (or deleted by an
4535 - // admin). Slack answers is_archived / channel_not_found — clear the
4536 - // stale option, mint a fresh channel, and re-post ONCE so the handoff
4537 - // is never silently dropped.
4538 - if (!is_wp_error($handoff_post)) {
4539 - $handoff_data = json_decode(wp_remote_retrieve_body($handoff_post), true);
4540 - $handoff_err = isset($handoff_data['error']) ? $handoff_data['error'] : '';
4541 - if (isset($handoff_data['ok']) && !$handoff_data['ok'] && in_array($handoff_err, array('is_archived', 'channel_not_found'), true)) {
4542 - delete_option("mxchat_channel_{$session_id}");
4543 - $channel_id = $this->mxchat_create_conversation_channel($session_id);
4544 - if (!empty($channel_id)) {
4545 - wp_remote_post('https://slack.com/api/chat.postMessage', [
4546 - 'headers' => [
4547 - 'Content-Type' => 'application/json',
4548 - 'Authorization' => 'Bearer ' . $slack_bot_token
4549 - ],
4550 - 'body' => json_encode([
4551 - 'channel' => $channel_id,
4552 - 'text' => $channel_message,
4553 - 'mrkdwn' => true
4554 - ])
4555 - ]);
4556 - }
4557 - }
4558 - }
4559 - }
4560 -
4561 2768 $success_message = $this->options['live_agent_notification_message'] ?? 'Live agent has been notified.';
4562 2769 $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
4563 2770
4564 2771 $this->fallbackResponse = [
@@ -4578,197 +2785,12 @@
4578 2785 ]);
4579 2786 wp_die();
4580 2787 }
4581 2788
4582 -/**
4583 - * Archive a session's per-conversation chat- channel after !endchat / session
4584 - * cleanup (plan 7458a7). HARD GUARDS, in order: the toggle must be on
4585 - * (default off = zero change for existing installs); a session with
4586 - * mxchat_thread_ set is a 9f7756 SHARED-channel session and is never
4587 - * archived; only the channel this session owns via mxchat_channel_ is
4588 - * archived, and only when it matches the channel the caller is acting on.
4589 - * Best-effort by design — a failed archive is logged and never blocks the
4590 - * mode flip or cleanup.
4591 - *
4592 - * @param string $session_id
4593 - * @param string $event_channel_id Channel the caller is acting on.
4594 - */
4595 -private function mxchat_maybe_archive_conversation_channel($session_id, $event_channel_id) {
4596 - $toggle = $this->options['live_agent_archive_on_end_toggle'] ?? 'off';
4597 - if ($toggle !== 'on') {
4598 - return;
4599 - }
4600 - if (get_option("mxchat_thread_{$session_id}", '') !== '') {
4601 - return; // shared-channel session — the shared channel is NEVER archived
4602 - }
4603 - $owned_channel = get_option("mxchat_channel_{$session_id}", '');
4604 - if ($owned_channel === '' || $owned_channel !== $event_channel_id) {
4605 - return;
4606 - }
4607 - $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4608 - if (empty($slack_bot_token)) {
4609 - return;
4610 - }
4611 - $response = wp_remote_post('https://slack.com/api/conversations.archive', [
4612 - 'headers' => [
4613 - 'Content-Type' => 'application/json',
4614 - 'Authorization' => 'Bearer ' . $slack_bot_token
4615 - ],
4616 - 'body' => json_encode(['channel' => $owned_channel])
4617 - ]);
4618 - if (is_wp_error($response)) {
4619 - error_log('MxChat: conversations.archive request failed: ' . $response->get_error_message());
4620 - return;
4621 - }
4622 - $data = json_decode(wp_remote_retrieve_body($response), true);
4623 - if (empty($data['ok'])) {
4624 - error_log('MxChat: conversations.archive returned error: ' . (isset($data['error']) ? $data['error'] : 'unknown'));
4625 - }
4626 -}
4627 -
4628 -/**
4629 - * Create a dedicated per-conversation Slack channel for a session and invite
4630 - * the configured agents. Extracted from mxchat_live_agent_handover so the
4631 - * shared-channel mode (plan 9f7756) can reuse it as its fallback path.
4632 - *
4633 - * @param string $session_id
4634 - * @return string Channel ID, or '' on failure.
4635 - */
4636 -private function mxchat_create_conversation_channel($session_id) {
4637 - $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4638 - if (empty($slack_bot_token)) {
4639 - return '';
4640 - }
4641 -
4642 - $channel_id = '';
4643 - $channel_name = $this->generate_channel_name($session_id);
4644 -
4645 - $response = wp_remote_post('https://slack.com/api/conversations.create', [
4646 - 'headers' => [
4647 - 'Content-Type' => 'application/json',
4648 - 'Authorization' => 'Bearer ' . $slack_bot_token
4649 - ],
4650 - 'body' => json_encode([
4651 - 'name' => $channel_name,
4652 - 'is_private' => false // Public channel - anyone in workspace can join
4653 - ])
4654 - ]);
4655 -
4656 - if (!is_wp_error($response)) {
4657 - $response_data = json_decode(wp_remote_retrieve_body($response), true);
4658 -
4659 - if (isset($response_data['ok']) && $response_data['ok']) {
4660 - $channel_id = $response_data['channel']['id'];
4661 - update_option("mxchat_channel_{$session_id}", $channel_id);
4662 -
4663 - // Auto-invite agents to the channel
4664 - $agent_user_ids = $this->options['live_agent_user_ids'] ?? '';
4665 -
4666 - if (!empty($agent_user_ids)) {
4667 - // Parse user IDs (one per line)
4668 - $user_ids = array_filter(array_map('trim', explode("\n", $agent_user_ids)));
4669 -
4670 - foreach ($user_ids as $user_id_to_invite) {
4671 - wp_remote_post('https://slack.com/api/conversations.invite', [
4672 - 'headers' => [
4673 - 'Content-Type' => 'application/json',
4674 - 'Authorization' => 'Bearer ' . $slack_bot_token
4675 - ],
4676 - 'body' => json_encode([
4677 - 'channel' => $channel_id,
4678 - 'users' => $user_id_to_invite
4679 - ])
4680 - ]);
4681 - }
4682 - }
4683 - }
4684 - }
4685 -
4686 - return $channel_id;
4687 -}
4688 -
4689 -/**
4690 - * Post a handoff (or a re-handover) into the configured shared channel.
4691 - * First post per session becomes the conversation's thread root; its ts is
4692 - * stored in mxchat_thread_{session} and every later message rides that
4693 - * thread. Records the Slack error for the settings page on failure so the
4694 - * caller can fall back to per-conversation creation.
4695 - *
4696 - * @param string $session_id
4697 - * @param string $text Fully-built handoff message.
4698 - * @param string $thread_ts Existing thread root for this session, '' if none.
4699 - * @return bool True when the message reached the shared channel.
4700 - */
4701 -private function mxchat_post_shared_handoff($session_id, $text, $thread_ts = '') {
4702 - $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
4703 - $configured = trim($this->options['live_agent_shared_channel'] ?? '');
4704 - if (empty($slack_bot_token) || $configured === '') {
4705 - return false;
4706 - }
4707 -
4708 - // Posting by #name works once the bot is a member; the response carries
4709 - // the real channel ID, cached so the inbound webhook and user-relay
4710 - // don't depend on how the admin wrote the setting.
4711 - $cache = get_option('mxchat_slack_shared_channel_id', array());
4712 - $target = (is_array($cache) && ($cache['configured'] ?? '') === $configured && !empty($cache['id']))
4713 - ? $cache['id']
4714 - : ltrim($configured, '#');
4715 -
4716 - $body = [
4717 - 'channel' => $target,
4718 - 'text' => $text,
4719 - 'mrkdwn' => true
4720 - ];
4721 - if ($thread_ts !== '') {
4722 - $body['thread_ts'] = $thread_ts;
4723 - }
4724 -
4725 - $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
4726 - 'headers' => [
4727 - 'Content-Type' => 'application/json',
4728 - 'Authorization' => 'Bearer ' . $slack_bot_token
4729 - ],
4730 - 'body' => json_encode($body)
4731 - ]);
4732 -
4733 - if (is_wp_error($response)) {
4734 - update_option('mxchat_slack_shared_channel_error', array(
4735 - 'error' => $response->get_error_message(),
4736 - 'configured' => $configured,
4737 - 'time' => time(),
4738 - ), false);
4739 - return false;
4740 - }
4741 -
4742 - $data = json_decode(wp_remote_retrieve_body($response), true);
4743 - if (empty($data['ok'])) {
4744 - update_option('mxchat_slack_shared_channel_error', array(
4745 - 'error' => $data['error'] ?? 'unknown_error',
4746 - 'configured' => $configured,
4747 - 'time' => time(),
4748 - ), false);
4749 - return false;
4750 - }
4751 -
4752 - delete_option('mxchat_slack_shared_channel_error');
4753 -
4754 - if (!empty($data['channel'])) {
4755 - update_option('mxchat_slack_shared_channel_id', array(
4756 - 'configured' => $configured,
4757 - 'id' => $data['channel'],
4758 - ), false);
4759 - }
4760 - if ($thread_ts === '' && !empty($data['ts'])) {
4761 - update_option("mxchat_thread_{$session_id}", $data['ts'], 'no');
4762 - }
4763 -
4764 - return true;
4765 -}
4766 -
4767 2789 private function generate_channel_name($session_id) {
4768 2790 $email = null;
4769 2791 $name = null;
4770 -
2792 +
4771 2793 // 1. First priority: Check if user is logged in and get their info
4772 2794 if (is_user_logged_in()) {
4773 2795 $current_user = wp_get_current_user();
4774 2796 if (!empty($current_user->user_email)) {
@@ -4876,410 +2898,12 @@
4876 2898
4877 2899 //error_log("[DEBUG] Generated channel name: {$channel_name}");
4878 2900 return $channel_name;
4879 2901 }
4880 -
4881 -/**
4882 - * Telegram Live Agent Handover
4883 - * Creates a forum topic in the Telegram group and notifies agents
4884 - */
4885 -public function mxchat_telegram_live_agent_handover($message, $user_id, $session_id) {
4886 - // Check if Telegram agents are available. Telegram has its OWN availability
4887 - // schedule, independent of Slack's (plan 99d7a4 — each Integrations tab
4888 - // owns its scheduler). Backstop only; the tool is normally withheld
4889 - // off-hours.
4890 - $telegram_available = $this->options['telegram_status'] ?? 'off';
4891 - $within_hours = !class_exists('MxChat_Live_Agent_Schedule')
4892 - || MxChat_Live_Agent_Schedule::is_within_hours('telegram');
4893 - if ($telegram_available !== 'on' || !$within_hours) {
4894 - $away_message = $this->options['telegram_away_message'] ?? 'Sorry, live agents are currently unavailable. I can continue helping you as an AI assistant.';
4895 - $this->fallbackResponse = [
4896 - 'text' => $away_message,
4897 - 'html' => '',
4898 - 'images' => [],
4899 - 'chat_mode' => 'ai'
4900 - ];
4901 - wp_send_json([
4902 - 'text' => $away_message,
4903 - 'html' => '',
4904 - 'chat_mode' => 'ai',
4905 - 'session_id' => $session_id
4906 - ]);
4907 - wp_die();
4908 - }
4909 -
4910 - $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
4911 - $telegram_group_id = $this->options['telegram_group_id'] ?? '';
4912 -
4913 - if (empty($telegram_bot_token) || empty($telegram_group_id)) {
4914 - return false;
4915 - }
4916 -
4917 - // Check if topic already exists for this session
4918 - $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
4919 -
4920 - if (empty($topic_id)) {
4921 - // Generate topic name
4922 - $topic_name = $this->generate_telegram_topic_name($session_id);
4923 -
4924 - // Random icon color (Telegram forum topic colors)
4925 - $icon_colors = [0x6FB9F0, 0xFFD67E, 0xCB86DB, 0x8EEE98, 0xFF93B2, 0xFB6F5F];
4926 - $icon_color = $icon_colors[array_rand($icon_colors)];
4927 -
4928 - // Create forum topic
4929 - $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/createForumTopic", [
4930 - 'headers' => ['Content-Type' => 'application/json'],
4931 - 'body' => json_encode([
4932 - 'chat_id' => $telegram_group_id,
4933 - 'name' => $topic_name,
4934 - 'icon_color' => $icon_color
4935 - ])
4936 - ]);
4937 -
4938 - if (!is_wp_error($response)) {
4939 - $response_body = wp_remote_retrieve_body($response);
4940 - $response_data = json_decode($response_body, true);
4941 -
4942 - if (isset($response_data['ok']) && $response_data['ok']) {
4943 - $topic_id = $response_data['result']['message_thread_id'];
4944 - update_option("mxchat_telegram_topic_{$session_id}", $topic_id);
4945 - update_option("mxchat_telegram_group_{$session_id}", $telegram_group_id);
4946 - }
4947 - }
4948 -
4949 - if (empty($topic_id)) {
4950 - return false; // Failed to create topic
4951 - }
4952 - }
4953 -
4954 - // Get recent chat history
4955 - $history = get_option("mxchat_history_{$session_id}", []);
4956 - $recent_history = array_slice($history, -5);
4957 -
4958 - // Format conversation context for Telegram (HTML format)
4959 - $conversation_context = "";
4960 - if (!empty($recent_history)) {
4961 - $conversation_context = "<b>Recent Conversation:</b>\n";
4962 - foreach ($recent_history as $hist_message) {
4963 - $role_display = $hist_message['role'] === 'user' ? '👤 User' : '🤖 AI';
4964 - $escaped_content = htmlspecialchars($hist_message['content'], ENT_QUOTES, 'UTF-8');
4965 - $conversation_context .= "{$role_display}: {$escaped_content}\n";
4966 - }
4967 - $conversation_context .= "\n";
4968 - }
4969 -
4970 - // Get user info
4971 - $user_email = get_option("mxchat_email_{$session_id}", 'Not provided');
4972 - $user_name = get_option("mxchat_name_{$session_id}", 'Anonymous');
4973 -
4974 - // Update session mode
4975 - update_option("mxchat_mode_{$session_id}", 'agent');
4976 -
4977 - // Send initial message to topic
4978 - $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
4979 - $topic_message = "🔔 <b>New Live Agent Request</b>\n\n";
4980 - $topic_message .= "<b>Session ID:</b> <code>{$session_id}</code>\n";
4981 - $topic_message .= "<b>User:</b> {$user_name}\n";
4982 - $topic_message .= "<b>Email:</b> {$user_email}\n\n";
4983 -
4984 - if (!empty($conversation_context)) {
4985 - $topic_message .= $conversation_context;
4986 - }
4987 -
4988 - $topic_message .= "<b>Current Message:</b>\n{$escaped_message}\n\n";
4989 - $topic_message .= "<i>Reply in this topic - messages will be sent to the user</i>\n";
4990 - $topic_message .= "<i>Type #close, #end, #disconnect, or #done to end the session</i>";
4991 -
4992 - wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
4993 - 'headers' => ['Content-Type' => 'application/json'],
4994 - 'body' => json_encode([
4995 - 'chat_id' => $telegram_group_id,
4996 - 'message_thread_id' => $topic_id,
4997 - 'text' => $topic_message,
4998 - 'parse_mode' => 'HTML'
4999 - ])
5000 - ]);
5001 -
5002 - $success_message = $this->options['telegram_notification_message'] ?? "I've notified a support agent. Please allow a moment for them to respond.";
5003 - $this->mxchat_save_chat_message($session_id, 'bot', $success_message);
5004 -
5005 - $this->fallbackResponse = [
5006 - 'text' => $success_message,
5007 - 'html' => '',
5008 - 'images' => [],
5009 - 'chat_mode' => 'agent'
5010 - ];
5011 -
5012 - wp_send_json([
5013 - 'success' => true,
5014 - 'text' => $success_message,
5015 - 'html' => '',
5016 - 'chat_mode' => 'agent',
5017 - 'session_id' => $session_id,
5018 - 'fallbackResponse' => $this->fallbackResponse
5019 - ]);
5020 - wp_die();
5021 -}
5022 -
5023 -/**
5024 - * Generate topic name for Telegram forum
5025 - */
5026 -private function generate_telegram_topic_name($session_id) {
5027 - $name = null;
5028 - $email = null;
5029 -
5030 - // Check logged in user
5031 - if (is_user_logged_in()) {
5032 - $current_user = wp_get_current_user();
5033 - if (!empty($current_user->display_name)) {
5034 - $name = $current_user->display_name;
5035 - }
5036 - if (!empty($current_user->user_email)) {
5037 - $email = $current_user->user_email;
5038 - }
5039 - }
5040 -
5041 - // Check session data
5042 - if (empty($name)) {
5043 - $name = get_option("mxchat_name_{$session_id}");
5044 - }
5045 - if (empty($email)) {
5046 - $email = get_option("mxchat_email_{$session_id}");
5047 - }
5048 -
5049 - // Generate topic name
5050 - $session_suffix = substr($session_id, -6);
5051 -
5052 - if (!empty($name)) {
5053 - // Clean name for topic (max 128 chars in Telegram)
5054 - $clean_name = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $name);
5055 - $clean_name = trim($clean_name);
5056 - if (strlen($clean_name) > 50) {
5057 - $clean_name = substr($clean_name, 0, 50);
5058 - }
5059 - return "Chat - {$clean_name} ({$session_suffix})";
5060 - } elseif (!empty($email)) {
5061 - // Use email prefix
5062 - $email_prefix = explode('@', $email)[0];
5063 - if (strlen($email_prefix) > 30) {
5064 - $email_prefix = substr($email_prefix, 0, 30);
5065 - }
5066 - return "Chat - {$email_prefix} ({$session_suffix})";
5067 - }
5068 -
5069 - return "Chat - {$session_suffix}";
5070 -}
5071 -
5072 -/**
5073 - * Send user message to Telegram agent
5074 - */
5075 -public function mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id) {
5076 - $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5077 - $topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5078 - $group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5079 -
5080 - if (empty($telegram_bot_token) || empty($topic_id) || empty($group_id)) {
5081 - return false;
5082 - }
5083 -
5084 - $escaped_message = htmlspecialchars($message, ENT_QUOTES, 'UTF-8');
5085 - $user_message = "👤 <b>User:</b> {$escaped_message}";
5086 -
5087 - $response = wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5088 - 'headers' => ['Content-Type' => 'application/json'],
5089 - 'body' => json_encode([
5090 - 'chat_id' => $group_id,
5091 - 'message_thread_id' => $topic_id,
5092 - 'text' => $user_message,
5093 - 'parse_mode' => 'HTML'
5094 - ])
5095 - ]);
5096 -
5097 - return !is_wp_error($response);
5098 -}
5099 -
5100 -/**
5101 - * Handle incoming Telegram webhook
5102 - */
5103 -public function handle_telegram_webhook(WP_REST_Request $request) {
5104 - $body = $request->get_body();
5105 - $data = json_decode($body, true);
5106 -
5107 - //error_log('[MxChat Telegram DEBUG] Webhook received: ' . $body);
5108 -
5109 - // Handle message events from forum topics
5110 - if (isset($data['message'])) {
5111 - $message_data = $data['message'];
5112 -
5113 - // Skip if not from a forum topic
5114 - if (!isset($message_data['message_thread_id'])) {
5115 - //error_log('[MxChat Telegram DEBUG] Skipped: No message_thread_id (not a forum topic message)');
5116 - return new WP_REST_Response(['ok' => true]);
5117 - }
5118 -
5119 - // Skip bot messages
5120 - if (isset($message_data['from']['is_bot']) && $message_data['from']['is_bot']) {
5121 - //error_log('[MxChat Telegram DEBUG] Skipped: Message from bot');
5122 - return new WP_REST_Response(['ok' => true]);
5123 - }
5124 -
5125 - $chat_id = $message_data['chat']['id'] ?? '';
5126 - $topic_id = $message_data['message_thread_id'];
5127 - $message_text = $message_data['text'] ?? '';
5128 - $message_id = $message_data['message_id'] ?? '';
5129 - $from = $message_data['from'] ?? [];
5130 - $agent_name = trim(($from['first_name'] ?? '') . ' ' . ($from['last_name'] ?? ''));
5131 - if (empty($agent_name)) {
5132 - $agent_name = $from['username'] ?? 'Agent';
5133 - }
5134 -
5135 - //error_log("[MxChat Telegram DEBUG] Parsed: chat_id={$chat_id}, topic_id={$topic_id}, agent={$agent_name}, text={$message_text}");
5136 -
5137 - // Skip empty messages
5138 - if (empty($message_text)) {
5139 - //error_log('[MxChat Telegram DEBUG] Skipped: Empty message text');
5140 - return new WP_REST_Response(['ok' => true]);
5141 - }
5142 -
5143 - // Find session ID by topic ID - cast to string for comparison
5144 - global $wpdb;
5145 - $topic_id_str = strval($topic_id);
5146 - $session_option = $wpdb->get_var(
5147 - $wpdb->prepare(
5148 - "SELECT option_name FROM {$wpdb->options}
5149 - WHERE option_name LIKE %s
5150 - AND option_value = %s",
5151 - 'mxchat_telegram_topic_%',
5152 - $topic_id_str
5153 - )
5154 - );
5155 -
5156 - //error_log("[MxChat Telegram DEBUG] Looking for topic_id={$topic_id_str} in options, found: " . ($session_option ?: 'NULL'));
5157 -
5158 - if ($session_option) {
5159 - $session_id = str_replace('mxchat_telegram_topic_', '', $session_option);
5160 - //error_log("[MxChat Telegram DEBUG] Session ID: {$session_id}");
5161 -
5162 - // Verify the group ID matches
5163 - $stored_group_id = get_option("mxchat_telegram_group_{$session_id}", '');
5164 - //error_log("[MxChat Telegram DEBUG] Stored group_id={$stored_group_id}, received chat_id={$chat_id}");
5165 -
5166 - if (strval($stored_group_id) != strval($chat_id)) {
5167 - //error_log('[MxChat Telegram DEBUG] Skipped: Group ID mismatch');
5168 - return new WP_REST_Response(['ok' => true]);
5169 - }
5170 -
5171 - // Check for closure commands
5172 - $lower_text = strtolower(trim($message_text));
5173 - if (in_array($lower_text, ['#close', '#end', '#disconnect', '#done'])) {
5174 - //error_log("[MxChat Telegram DEBUG] Closure command received: {$lower_text}");
5175 - // End the live agent session
5176 - update_option("mxchat_mode_{$session_id}", 'ai');
5177 -
5178 - // Save disconnect message
5179 - $disconnect_message = "Live agent session ended. You're now chatting with the AI assistant.";
5180 - $this->mxchat_save_chat_message($session_id, 'bot', $disconnect_message);
5181 -
5182 - // Notify in Telegram
5183 - $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5184 - if (!empty($telegram_bot_token)) {
5185 - wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5186 - 'headers' => ['Content-Type' => 'application/json'],
5187 - 'body' => json_encode([
5188 - 'chat_id' => $chat_id,
5189 - 'message_thread_id' => $topic_id,
5190 - 'text' => "✅ Session closed. User returned to AI chatbot.",
5191 - 'parse_mode' => 'HTML'
5192 - ])
5193 - ]);
5194 -
5195 - // Optionally close the topic
5196 - wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/closeForumTopic", [
5197 - 'headers' => ['Content-Type' => 'application/json'],
5198 - 'body' => json_encode([
5199 - 'chat_id' => $chat_id,
5200 - 'message_thread_id' => $topic_id
5201 - ])
5202 - ]);
5203 - }
5204 -
5205 - return new WP_REST_Response(['ok' => true]);
5206 - }
5207 -
5208 - // Deduplicate messages
5209 - $message_key = md5($session_id . $message_id . $message_text);
5210 - $processed_messages = get_transient('mxchat_telegram_messages_' . $session_id) ?: [];
5211 -
5212 - if (in_array($message_key, $processed_messages)) {
5213 - //error_log('[MxChat Telegram DEBUG] Skipped: Duplicate message');
5214 - return new WP_REST_Response(['ok' => true]);
5215 - }
5216 -
5217 - $processed_messages[] = $message_key;
5218 - if (count($processed_messages) > 50) {
5219 - $processed_messages = array_slice($processed_messages, -50);
5220 - }
5221 - set_transient('mxchat_telegram_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5222 -
5223 - // Save the agent message - format with agent name prefix for proper parsing
5224 - $formatted_message = "Agent: {$agent_name} - {$message_text}";
5225 - //error_log("[MxChat Telegram DEBUG] Saving agent message: {$formatted_message}");
5226 -
5227 - $this->mxchat_save_chat_message($session_id, 'agent', $formatted_message);
5228 -
5229 - // Verify the message was saved to history
5230 - $history = get_option("mxchat_history_{$session_id}", []);
5231 - $last_message = end($history);
5232 - //error_log("[MxChat Telegram DEBUG] History after save - count: " . count($history) . ", last message role: " . ($last_message['role'] ?? 'none'));
5233 -
5234 - // Send confirmation back to Telegram
5235 - $telegram_bot_token = $this->options['telegram_bot_token'] ?? '';
5236 - if (!empty($telegram_bot_token)) {
5237 - $confirm_key = 'mxchat_telegram_confirm_' . $message_key;
5238 - if (!get_transient($confirm_key)) {
5239 - wp_remote_post("https://api.telegram.org/bot{$telegram_bot_token}/sendMessage", [
5240 - 'headers' => ['Content-Type' => 'application/json'],
5241 - 'body' => json_encode([
5242 - 'chat_id' => $chat_id,
5243 - 'message_thread_id' => $topic_id,
5244 - 'text' => "✅ <i>Message sent to user</i>",
5245 - 'parse_mode' => 'HTML',
5246 - 'reply_to_message_id' => $message_id
5247 - ])
5248 - ]);
5249 - set_transient($confirm_key, true, 300);
5250 - }
5251 - }
5252 - } else {
5253 - //error_log("[MxChat Telegram DEBUG] No session found for topic_id={$topic_id}");
5254 - }
5255 - } else {
5256 - //error_log('[MxChat Telegram DEBUG] No message in webhook data');
5257 - }
5258 -
5259 - return new WP_REST_Response(['ok' => true]);
5260 -}
5261 -
5262 2902 public function mxchat_send_user_message_to_agent($message, $user_id, $session_id) {
5263 - // Check if this is a Telegram agent session
5264 - $telegram_topic_id = get_option("mxchat_telegram_topic_{$session_id}", '');
5265 - if (!empty($telegram_topic_id)) {
5266 - return $this->mxchat_send_user_message_to_telegram_agent($message, $user_id, $session_id);
5267 - }
5268 -
5269 - // Otherwise, try Slack
5270 2903 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
2904 + $channel_id = get_option("mxchat_channel_{$session_id}", '');
5271 2905
5272 - // Shared-channel session: the conversation lives in a thread of the
5273 - // shared channel (plan 9f7756); relay user messages into that thread.
5274 - $thread_ts = get_option("mxchat_thread_{$session_id}", '');
5275 - if (!empty($thread_ts)) {
5276 - $cache = get_option('mxchat_slack_shared_channel_id', array());
5277 - $channel_id = is_array($cache) ? ($cache['id'] ?? '') : '';
5278 - } else {
5279 - $channel_id = get_option("mxchat_channel_{$session_id}", '');
5280 - }
5281 -
5282 2906 if (empty($slack_bot_token) || empty($channel_id)) {
5283 2907 return false;
5284 2908 }
5285 2909
@@ -5284,23 +2908,18 @@
5284 2908 }
5285 2909
5286 2910 $user_message = "💬 *User:* {$message}";
5287 2911
5288 - $body = [
5289 - 'channel' => $channel_id,
5290 - 'text' => $user_message,
5291 - 'mrkdwn' => true
5292 - ];
5293 - if (!empty($thread_ts)) {
5294 - $body['thread_ts'] = $thread_ts;
5295 - }
5296 -
5297 2912 $response = wp_remote_post('https://slack.com/api/chat.postMessage', [
5298 2913 'headers' => [
5299 2914 'Content-Type' => 'application/json',
5300 2915 'Authorization' => 'Bearer ' . $slack_bot_token
5301 2916 ],
5302 - 'body' => json_encode($body)
2917 + 'body' => json_encode([
2918 + 'channel' => $channel_id,
2919 + 'text' => $user_message,
2920 + 'mrkdwn' => true
2921 + ])
5303 2922 ]);
5304 2923
5305 2924 return !is_wp_error($response);
5306 2925 }
@@ -5456,90 +3075,8 @@
5456 3075 // Return the complete response array instead of just true
5457 3076 return $this->fallbackResponse;
5458 3077 }
5459 3078
5460 -/**
5461 - * Normalize Slack mrkdwn before relaying an agent's message to the web visitor.
5462 - * Slack's Events API auto-wraps URLs as <https://url> or <https://url|Label>, wraps
5463 - * mentions as <@U…>/<#C…|name>, and HTML-escapes &, <, >. Relayed raw, the visitor
5464 - * sees a broken/doubled link with a trailing > (plan-e2195b). Unwrap links FIRST, then
5465 - * unescape entities LAST so extracted URLs (which can contain &amp;) are not corrupted.
5466 - */
5467 -private function normalize_slack_text($text) {
5468 - if (!is_string($text) || $text === '') {
5469 - return $text;
5470 - }
5471 -
5472 - $text = preg_replace_callback('/<([^>|]+)(?:\|([^>]*))?>/', function ($m) {
5473 - $target = $m[1];
5474 - $label = isset($m[2]) ? $m[2] : '';
5475 -
5476 - // User/channel mentions: <@U…> or <#C…|name> — prefer the human label, else drop the id.
5477 - if (isset($target[0]) && ($target[0] === '@' || $target[0] === '#')) {
5478 - return $label !== '' ? $label : '';
5479 - }
5480 - // mailto:/tel: — strip the scheme for display.
5481 - if (stripos($target, 'mailto:') === 0) {
5482 - $addr = substr($target, 7);
5483 - return ($label !== '' && $label !== $addr) ? "{$label} ({$addr})" : $addr;
5484 - }
5485 - if (stripos($target, 'tel:') === 0) {
5486 - $num = substr($target, 4);
5487 - return ($label !== '' && $label !== $num) ? "{$label} ({$num})" : $num;
5488 - }
5489 - // Regular URL: <url|Label> -> "Label (url)"; bare <url> -> "url".
5490 - if ($label !== '' && $label !== $target) {
5491 - return "{$label} ({$target})";
5492 - }
5493 - return $target;
5494 - }, $text);
5495 -
5496 - // Entity-unescape LAST (after link extraction) so &amp; inside URLs is repaired too.
5497 - $text = str_replace(array('&amp;', '&lt;', '&gt;'), array('&', '<', '>'), $text);
5498 -
5499 - return $text;
5500 -}
5501 -
5502 -/**
5503 - * Resolve the visitor's name + email for a session, mirroring generate_channel_name()'s
5504 - * priority order: logged-in user, then the pre-chat gate options (mxchat_email_/mxchat_name_),
5505 - * then the chat transcript. Returns ['name' => ..., 'email' => ...] (either may be ''). plan-e2195b.
5506 - */
5507 -private function mxchat_get_visitor_identity($session_id) {
5508 - $email = '';
5509 - $name = '';
5510 -
5511 - if (is_user_logged_in()) {
5512 - $current_user = wp_get_current_user();
5513 - if (!empty($current_user->user_email)) { $email = $current_user->user_email; }
5514 - if (!empty($current_user->display_name)) { $name = $current_user->display_name; }
5515 - }
5516 -
5517 - if (empty($email)) {
5518 - $saved_email = get_option("mxchat_email_{$session_id}", '');
5519 - if (!empty($saved_email)) { $email = $saved_email; }
5520 - }
5521 - if (empty($name)) {
5522 - $saved_name = get_option("mxchat_name_{$session_id}", '');
5523 - if (!empty($saved_name)) { $name = $saved_name; }
5524 - }
5525 -
5526 - if (empty($email) || empty($name)) {
5527 - global $wpdb;
5528 - $table_name = $wpdb->prefix . 'mxchat_chat_transcripts';
5529 - $existing_data = $wpdb->get_row($wpdb->prepare(
5530 - "SELECT user_email, user_name FROM $table_name WHERE session_id = %s AND (user_email IS NOT NULL OR user_name IS NOT NULL) LIMIT 1",
5531 - $session_id
5532 - ));
5533 - if ($existing_data) {
5534 - if (empty($email) && !empty($existing_data->user_email)) { $email = $existing_data->user_email; }
5535 - if (empty($name) && !empty($existing_data->user_name)) { $name = $existing_data->user_name; }
5536 - }
5537 - }
5538 -
5539 - return array('name' => $name, 'email' => $email);
5540 -}
5541 -
5542 3079 public function handle_slack_messages(WP_REST_Request $request) {
5543 3080 // Log the incoming request for debugging
5544 3081 //error_log('Slack events request received: ' . $request->get_body());
5545 3082
@@ -5581,45 +3118,41 @@
5581 3118 if (isset($event['bot_id']) || isset($event['subtype'])) {
5582 3119 return new WP_REST_Response(['ok' => true]);
5583 3120 }
5584 3121
5585 - // Threaded replies: in shared-channel mode every conversation lives in
5586 - // a thread rooted at its handoff message — route those to their session
5587 - // by thread root (plan 9f7756). Any other threaded reply (e.g. under a
5588 - // per-conversation channel's confirmation message) finds no session and
5589 - // is skipped, exactly as before.
3122 + // Additional check: Skip if this is a threaded reply to our confirmation
5590 3123 if (isset($event['thread_ts']) && $event['thread_ts'] !== $event['ts']) {
5591 - return $this->mxchat_route_shared_thread_reply($event);
3124 + return new WP_REST_Response(['ok' => true]);
5592 3125 }
5593 3126
5594 3127 $channel_id = $event['channel'];
5595 3128 $message_text = $event['text'] ?? '';
5596 3129 $message_ts = $event['ts'] ?? '';
5597 -
3130 +
5598 3131 // Find session ID by looking for matching channel
5599 3132 global $wpdb;
5600 3133 $session_option = $wpdb->get_var(
5601 3134 $wpdb->prepare(
5602 - "SELECT option_name FROM {$wpdb->options}
5603 - WHERE option_name LIKE 'mxchat_channel_%'
3135 + "SELECT option_name FROM {$wpdb->options}
3136 + WHERE option_name LIKE 'mxchat_channel_%'
5604 3137 AND option_value = %s",
5605 3138 $channel_id
5606 3139 )
5607 3140 );
5608 -
3141 +
5609 3142 if ($session_option) {
5610 3143 $session_id = str_replace('mxchat_channel_', '', $session_option);
5611 -
3144 +
5612 3145 // Create a unique key for this specific message
5613 3146 $message_key = md5($session_id . $message_ts . $message_text);
5614 3147 $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5615 -
3148 +
5616 3149 // Check if we've already processed this exact message
5617 3150 if (in_array($message_key, $processed_messages)) {
5618 3151 //error_log("Duplicate message detected for session $session_id");
5619 3152 return new WP_REST_Response(['ok' => true]);
5620 3153 }
5621 -
3154 +
5622 3155 // Add to processed messages
5623 3156 $processed_messages[] = $message_key;
5624 3157 // Keep only last 50 messages per session
5625 3158 if (count($processed_messages) > 50) {
@@ -5625,50 +3158,14 @@
5625 3158 if (count($processed_messages) > 50) {
5626 3159 $processed_messages = array_slice($processed_messages, -50);
5627 3160 }
5628 3161 set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5629 -
3162 +
3163 + // Save the agent message
3164 + $this->mxchat_save_chat_message($session_id, 'agent', $message_text);
3165 +
3166 + // Send confirmation back to Slack (only once)
5630 3167 $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5631 -
5632 - // Handle agent ending the chat — transfer back to AI
5633 - // Format: "!endchat" or "!endchat <custom message to user>"
5634 - if (preg_match('/^!endchat\b/i', trim($message_text))) {
5635 - update_option("mxchat_mode_{$session_id}", 'ai');
5636 -
5637 - // Extract custom message after !endchat, or use empty string
5638 - $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5639 -
5640 - // Send the agent's custom farewell message if provided
5641 - if (!empty($custom_message)) {
5642 - $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5643 - }
5644 -
5645 - // Confirm in Slack channel
5646 - if (!empty($slack_bot_token)) {
5647 - wp_remote_post('https://slack.com/api/chat.postMessage', [
5648 - 'headers' => [
5649 - 'Content-Type' => 'application/json',
5650 - 'Authorization' => 'Bearer ' . $slack_bot_token
5651 - ],
5652 - 'body' => json_encode([
5653 - 'channel' => $channel_id,
5654 - 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5655 - 'mrkdwn' => true
5656 - ])
5657 - ]);
5658 - }
5659 -
5660 - // Auto-archive the ended conversation's channel (plan 7458a7).
5661 - // Toggle-gated, best-effort — never blocks the mode flip.
5662 - $this->mxchat_maybe_archive_conversation_channel($session_id, $channel_id);
5663 -
5664 - return new WP_REST_Response(['ok' => true]);
5665 - }
5666 -
5667 - // Save the agent message (normalize Slack link/entity formatting first — plan-e2195b)
5668 - $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5669 -
5670 - // Send confirmation back to Slack (only once)
5671 3168 if (!empty($slack_bot_token)) {
5672 3169 // Use a transient to prevent duplicate confirmations
5673 3170 $confirm_key = 'mxchat_confirm_' . $message_key;
5674 3171 if (!get_transient($confirm_key)) {
@@ -5678,9 +3175,9 @@
5678 3175 'Authorization' => 'Bearer ' . $slack_bot_token
5679 3176 ],
5680 3177 'body' => json_encode([
5681 3178 'channel' => $channel_id,
5682 - 'text' => "✅ _Message sent to user_",
3179 + 'text' => "✅ _Message sent to user_",
5683 3180 'thread_ts' => $event['ts'] // Reply in thread
5684 3181 ])
5685 3182 ]);
5686 3183 // Set transient to prevent duplicate confirmations
@@ -5692,114 +3189,8 @@
5692 3189
5693 3190 return new WP_REST_Response(['ok' => true]);
5694 3191 }
5695 3192
5696 -/**
5697 - * Route an agent's threaded Slack reply to the session whose shared-channel
5698 - * conversation is rooted at that thread (plan 9f7756). Sessions are keyed by
5699 - * the thread root ts stored in mxchat_thread_{session}, so two visitors in
5700 - * the same shared channel can never cross-wire. Unknown threads are ignored.
5701 - *
5702 - * @param array $event Slack message event (has thread_ts !== ts).
5703 - * @return WP_REST_Response
5704 - */
5705 -private function mxchat_route_shared_thread_reply($event) {
5706 - $thread_root = $event['thread_ts'] ?? '';
5707 - $message_text = $event['text'] ?? '';
5708 - $message_ts = $event['ts'] ?? '';
5709 - $channel_id = $event['channel'] ?? '';
5710 -
5711 - if ($thread_root === '') {
5712 - return new WP_REST_Response(['ok' => true]);
5713 - }
5714 -
5715 - // Find the session owning this thread root (same reverse-lookup shape as
5716 - // the per-conversation channel mapping).
5717 - global $wpdb;
5718 - $session_option = $wpdb->get_var(
5719 - $wpdb->prepare(
5720 - "SELECT option_name FROM {$wpdb->options}
5721 - WHERE option_name LIKE 'mxchat_thread_%'
5722 - AND option_value = %s",
5723 - $thread_root
5724 - )
5725 - );
5726 -
5727 - if (!$session_option) {
5728 - // Not a shared-channel conversation thread (e.g. a reply under a
5729 - // per-conversation confirmation) — ignore, as before.
5730 - return new WP_REST_Response(['ok' => true]);
5731 - }
5732 -
5733 - $session_id = str_replace('mxchat_thread_', '', $session_option);
5734 -
5735 - // Per-message dedupe — same transient pattern as the top-level handler.
5736 - $message_key = md5($session_id . $message_ts . $message_text);
5737 - $processed_messages = get_transient('mxchat_processed_messages_' . $session_id) ?: [];
5738 - if (in_array($message_key, $processed_messages)) {
5739 - return new WP_REST_Response(['ok' => true]);
5740 - }
5741 - $processed_messages[] = $message_key;
5742 - if (count($processed_messages) > 50) {
5743 - $processed_messages = array_slice($processed_messages, -50);
5744 - }
5745 - set_transient('mxchat_processed_messages_' . $session_id, $processed_messages, HOUR_IN_SECONDS);
5746 -
5747 - $slack_bot_token = $this->options['live_agent_bot_token'] ?? '';
5748 -
5749 - // Agent ending the chat from inside the thread — same command contract as
5750 - // per-conversation channels: "!endchat" or "!endchat <farewell>".
5751 - if (preg_match('/^!endchat\b/i', trim($message_text))) {
5752 - update_option("mxchat_mode_{$session_id}", 'ai');
5753 -
5754 - $custom_message = trim(preg_replace('/^!endchat\s*/i', '', trim($message_text)));
5755 - if (!empty($custom_message)) {
5756 - $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($custom_message));
5757 - }
5758 -
5759 - if (!empty($slack_bot_token) && $channel_id !== '') {
5760 - wp_remote_post('https://slack.com/api/chat.postMessage', [
5761 - 'headers' => [
5762 - 'Content-Type' => 'application/json',
5763 - 'Authorization' => 'Bearer ' . $slack_bot_token
5764 - ],
5765 - 'body' => json_encode([
5766 - 'channel' => $channel_id,
5767 - 'text' => "✅ *Chat ended.* User has been transferred back to AI mode.",
5768 - 'thread_ts' => $thread_root,
5769 - 'mrkdwn' => true
5770 - ])
5771 - ]);
5772 - }
5773 -
5774 - return new WP_REST_Response(['ok' => true]);
5775 - }
5776 -
5777 - // Save the agent message for the widget (normalized like the channel path).
5778 - $this->mxchat_save_chat_message($session_id, 'agent', $this->normalize_slack_text($message_text));
5779 -
5780 - // Confirmation stays inside the conversation's thread.
5781 - if (!empty($slack_bot_token) && $channel_id !== '') {
5782 - $confirm_key = 'mxchat_confirm_' . $message_key;
5783 - if (!get_transient($confirm_key)) {
5784 - wp_remote_post('https://slack.com/api/chat.postMessage', [
5785 - 'headers' => [
5786 - 'Content-Type' => 'application/json',
5787 - 'Authorization' => 'Bearer ' . $slack_bot_token
5788 - ],
5789 - 'body' => json_encode([
5790 - 'channel' => $channel_id,
5791 - 'text' => "✅ _Message sent to user_",
5792 - 'thread_ts' => $thread_root
5793 - ])
5794 - ]);
5795 - set_transient($confirm_key, true, 300);
5796 - }
5797 - }
5798 -
5799 - return new WP_REST_Response(['ok' => true]);
5800 -}
5801 -
5802 3193 // For the word upload handler
5803 3194 public function mxchat_handle_word_upload() {
5804 3195 // Delegate to word handler
5805 3196 $this->word_handler->mxchat_handle_word_upload();
@@ -5826,15 +3217,9 @@
5826 3217 try {
5827 3218 // Get options and selected model
5828 3219 $options = get_option('mxchat_options');
5829 3220 $selected_model = $options['embedding_model'] ?? 'text-embedding-ada-002';
5830 -
5831 - // Opt-in: route embeddings through the Custom (OpenAI-compatible) provider.
5832 - // Off by default so existing sites see byte-identical behavior.
5833 - if (!empty($options['custom_provider_for_embeddings']) && $options['custom_provider_for_embeddings'] === 'on') {
5834 - return $this->mxchat_generate_embedding_custom($text);
5835 - }
5836 -
3221 +
5837 3222 // Determine endpoint and API key based on model
5838 3223 if (strpos($selected_model, 'voyage') === 0) {
5839 3224 $endpoint = 'https://api.voyageai.com/v1/embeddings';
5840 3225 $api_key = $options['voyage_api_key'] ?? '';
@@ -5947,11 +3332,13 @@
5947 3332 $status_code = wp_remote_retrieve_response_code($response);
5948 3333 if ($status_code !== 200) {
5949 3334 $response_body = json_decode(wp_remote_retrieve_body($response), true);
5950 3335
5951 - $error_message = $this->extract_provider_error($response_body, 'HTTP Error ' . $status_code);
5952 -
5953 - $error_type = isset($response_body['error']['type'])
3336 + $error_message = isset($response_body['error']['message'])
3337 + ? $response_body['error']['message']
3338 + : 'HTTP Error ' . $status_code;
3339 +
3340 + $error_type = isset($response_body['error']['type'])
5954 3341 ? $response_body['error']['type']
5955 3342 : 'unknown';
5956 3343
5957 3344 //error_log('Embedding API HTTP Error: ' . $status_code . ' - ' . $error_message);
@@ -6028,849 +3415,251 @@
6028 3415 ];
6029 3416 }
6030 3417 }
6031 3418
3419 +private function mxchat_find_relevant_content($user_embedding) {
3420 + //error_log('MXChat Vector Search: Starting content search...');
6032 3421
6033 -/**
6034 - * Generate embedding via a Custom (OpenAI-compatible) provider's /embeddings route.
6035 - * Only called when the opt-in 'custom_provider_for_embeddings' setting is on.
6036 - * Returns a numeric array (the embedding vector) on success, or ['error','error_code'] on failure.
6037 - */
6038 -private function mxchat_generate_embedding_custom($text) {
6039 - if (empty($text)) {
6040 - return ['error' => esc_html__('No text provided for embedding generation', 'mxchat'), 'error_code' => 'empty_embedding_text'];
6041 - }
6042 - $cfg = $this->mxchat_resolve_custom_provider();
6043 - if (empty($cfg['base_url'])) {
6044 - return ['error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url'];
6045 - }
3422 + // Retrieve the add-on settings from the database.
3423 + $addon_options = get_option('mxchat_pinecone_addon_options', array());
6046 3424
6047 - $options = get_option('mxchat_options');
6048 - $embed_url = $cfg['base_url'] . '/embeddings';
6049 - if (!empty($cfg['api_version'])) {
6050 - $embed_url .= (strpos($embed_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($cfg['api_version']);
6051 - }
6052 - $model = isset($options['custom_provider_embedding_model']) && trim((string) $options['custom_provider_embedding_model']) !== ''
6053 - ? trim((string) $options['custom_provider_embedding_model'])
6054 - : $cfg['model'];
3425 + // Determine whether Pinecone is enabled.
3426 + $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1') ? 1 : 0;
6055 3427
6056 - $response = wp_remote_post($embed_url, [
6057 - 'headers' => $this->mxchat_custom_provider_assoc_headers($cfg),
6058 - 'body' => wp_json_encode(['input' => $text, 'model' => $model]),
6059 - 'timeout' => 60,
6060 - ]);
6061 - if (is_wp_error($response)) {
6062 - return [
6063 - 'error' => esc_html__('Connection error when generating embeddings (custom provider): ', 'mxchat') . esc_html($response->get_error_message()),
6064 - 'error_code' => 'embedding_custom_connection_error',
6065 - ];
6066 - }
6067 - $status = wp_remote_retrieve_response_code($response);
6068 - $body = json_decode(wp_remote_retrieve_body($response), true);
6069 - if ($status !== 200) {
6070 - $msg = $this->extract_provider_error($body, 'HTTP ' . $status);
6071 - return [
6072 - 'error' => esc_html__('Custom embedding endpoint error: ', 'mxchat') . esc_html($msg),
6073 - 'error_code' => 'embedding_custom_api_error',
6074 - 'status_code' => $status,
6075 - ];
6076 - }
6077 - if (isset($body['data'][0]['embedding']) && is_array($body['data'][0]['embedding'])) {
6078 - return $body['data'][0]['embedding'];
6079 - }
6080 - return [
6081 - 'error' => esc_html__('Invalid embedding response from custom provider.', 'mxchat'),
6082 - 'error_code' => 'embedding_custom_invalid_response',
6083 - ];
6084 -}
3428 + //error_log('Pinecone enabled flag: ' . $use_pinecone);
6085 3429
6086 -private function mxchat_find_relevant_content($user_embedding, $bot_id = 'default', $user_query = '') {
6087 - //error_log("MXCHAT DEBUG: find_relevant_content called with bot_id: " . $bot_id);
6088 -
6089 - // Check for OpenAI Vector Store first (takes priority when enabled)
6090 - $bot_vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
6091 -
6092 - if ($bot_vectorstore_config['use_vectorstore']) {
6093 - // Get current model to verify it's an OpenAI model
6094 - $bot_options = $this->get_bot_options($bot_id);
6095 - $mxchat_options = get_option('mxchat_options', array());
6096 - $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
6097 - $selected_model = $current_options['model'] ?? 'gpt-5.6-sol';
6098 -
6099 - if ($this->is_openai_chat_model($selected_model)) {
6100 - //error_log("MXCHAT DEBUG: Using OpenAI Vector Store for knowledge retrieval");
6101 - return $this->find_relevant_content_openai_vectorstore($user_query, $bot_id, $bot_vectorstore_config);
6102 - } else {
6103 - //error_log("MXCHAT DEBUG: Vector Store enabled but model is not OpenAI (" . $selected_model . "), skipping Vector Store");
6104 - }
6105 - }
6106 -
6107 - // Get bot-specific Pinecone configuration
6108 - $bot_pinecone_config = $this->get_bot_pinecone_config($bot_id);
6109 -
6110 - // Debug: Log the Pinecone configuration
6111 - //error_log("MXCHAT DEBUG: Pinecone config for bot '$bot_id':");
6112 - //error_log(" - use_pinecone: " . ($bot_pinecone_config['use_pinecone'] ? 'true' : 'false'));
6113 - //error_log(" - api_key: " . (empty($bot_pinecone_config['api_key']) ? 'EMPTY' : 'SET (hidden)'));
6114 - //error_log(" - host: " . ($bot_pinecone_config['host'] ?? 'NOT SET'));
6115 - //error_log(" - namespace: " . ($bot_pinecone_config['namespace'] ?? 'NOT SET'));
6116 -
6117 - // Determine whether to use Pinecone based on bot configuration
6118 - $use_pinecone = isset($bot_pinecone_config['use_pinecone']) ? $bot_pinecone_config['use_pinecone'] : false;
6119 -
6120 - //error_log("MXCHAT DEBUG: Using " . ($use_pinecone ? "Pinecone" : "WordPress Database") . " for knowledge retrieval");
6121 -
6122 - if ($use_pinecone) {
6123 - return $this->find_relevant_content_pinecone($user_embedding, $bot_id, $bot_pinecone_config);
3430 + if ($use_pinecone === 1) {
3431 + //error_log('MXChat Vector Search: Using Pinecone database');
3432 + return $this->find_relevant_content_pinecone($user_embedding);
6124 3433 } else {
6125 - return $this->find_relevant_content_wordpress($user_embedding, $bot_id, $user_query);
3434 + //error_log('MXChat Vector Search: Using WordPress database');
3435 + return $this->find_relevant_content_wordpress($user_embedding);
6126 3436 }
6127 3437 }
6128 3438
6129 -private function find_relevant_content_wordpress($user_embedding, $bot_id = 'default', $user_query = '') {
3439 +private function find_relevant_content_wordpress($user_embedding) {
6130 3440 global $wpdb;
6131 3441 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
3442 + $cache_key = 'mxchat_system_prompt_embeddings';
3443 + $batch_size = 500;
3444 +
6132 3445 // Initialize similarity analysis storage
6133 3446 $this->last_similarity_analysis = [
6134 3447 'knowledge_base_type' => 'WordPress Database',
6135 - 'bot_id' => $bot_id,
6136 3448 'top_matches' => [],
6137 3449 'threshold_used' => 0,
6138 3450 'total_checked' => 0
6139 3451 ];
6140 3452
6141 - // NEW: Initialize valid URLs array
6142 - $valid_urls = [];
3453 + // Retrieve embeddings from cache or database
3454 + $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
3455 + if ($embeddings === false) {
3456 + // Cache miss - load embeddings from database WITH CONTENT and ROLE RESTRICTION for testing
3457 + $embeddings = [];
3458 + $offset = 0;
6143 3459
6144 - // Get bot-specific options for similarity threshold
6145 - $bot_options = $this->get_bot_options($bot_id);
6146 - $current_options = !empty($bot_options) ? $bot_options : $this->options;
3460 + do {
3461 + $query = $wpdb->prepare(
3462 + "SELECT id, embedding_vector, article_content, source_url, role_restriction
3463 + FROM {$system_prompt_table}
3464 + LIMIT %d OFFSET %d",
3465 + $batch_size,
3466 + $offset
3467 + );
6147 3468
6148 - // Get knowledge manager instance for role checking
6149 - $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
3469 + $batch = $wpdb->get_results($query);
3470 + if (empty($batch)) {
3471 + break;
3472 + }
6150 3473
6151 - // Get base similarity threshold from bot options or default options
6152 - $similarity_threshold = isset($current_options['similarity_threshold'])
6153 - ? ((int) $current_options['similarity_threshold']) / 100
6154 - : 0.35;
6155 - $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
3474 + $embeddings = array_merge($embeddings, $batch);
3475 + $offset += $batch_size;
3476 + unset($batch);
3477 + } while (true);
6156 3478
6157 - // Precompute bot_filter once, outside the streaming loop
6158 - $bot_filter = '';
6159 - if ($bot_id !== 'default') {
6160 - $column_exists = $wpdb->get_var("SHOW COLUMNS FROM {$system_prompt_table} LIKE 'bot_metadata'");
6161 - if ($column_exists) {
6162 - $bot_filter = $wpdb->prepare(" AND (bot_metadata = %s OR bot_metadata IS NULL OR bot_metadata = '')", $bot_id);
3479 + if (empty($embeddings)) {
3480 + return '';
6163 3481 }
3482 +
3483 + // Cache embeddings for future use (but note: this now includes content and role restrictions)
3484 + wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
6164 3485 }
6165 3486
6166 - // Hybrid keyword boost (plan-38ffa1, default OFF). Runs a ranked keyword
6167 - // query alongside the vector scan and fuses the two lists by reciprocal
6168 - // rank, so exact-token queries (SKUs, error codes, names) hit even when
6169 - // their embedding similarity is semantic mush. The keyword leg runs FIRST
6170 - // so the vector scan below can record true cosine similarity for its hits
6171 - // (the display keeps cosine % as the anchor).
6172 - $hybrid_enabled = get_option('mxchat_hybrid_keyword_toggle', 'off') === 'on'
6173 - && trim((string) $user_query) !== '';
6174 - $keyword_hits = array(); // ranked + access-filtered, max 20
6175 - $keyword_ids = array(); // id => keyword rank (1-based)
6176 - $keyword_similarities = array(); // id => cosine recorded during the scan
6177 - if ($hybrid_enabled) {
6178 - $keyword_hits = $this->mxchat_hybrid_keyword_search($user_query, $system_prompt_table, $bot_filter, $knowledge_manager);
6179 - foreach ($keyword_hits as $kw_i => $kw_hit) {
6180 - $keyword_ids[$kw_hit['id']] = $kw_i + 1;
6181 - }
6182 - }
3487 + // NEW: Get knowledge manager instance for role checking
3488 + $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6183 3489
6184 - // ===== STREAMING TOP-K PASS =====
6185 - // Stream rows in small batches, compute cosine similarity per row, and keep only:
6186 - // - top 10 by raw similarity (for the testing/debug display panel)
6187 - // - candidates above threshold with access (capped) for context assembly
6188 - // This bounds peak memory regardless of knowledge base size and avoids loading
6189 - // article_content for every row. article_content is fetched in Phase 2 for winners only.
6190 - $batch_size = 250;
6191 - $max_candidates = 200; // safety cap, well above rag_sources_limit * max_chunks_per_source
6192 - $top_display = [];
6193 - $candidates = [];
6194 - $total_checked = 0;
6195 - $offset = 0;
6196 -
6197 - do {
6198 - $batch = $wpdb->get_results($wpdb->prepare(
6199 - "SELECT id, embedding_vector, source_url, role_restriction
6200 - FROM {$system_prompt_table}
6201 - WHERE 1=1 {$bot_filter}
6202 - LIMIT %d OFFSET %d",
6203 - $batch_size,
6204 - $offset
6205 - ));
6206 -
6207 - if (empty($batch)) {
6208 - break;
6209 - }
6210 -
6211 - foreach ($batch as $row) {
6212 - $database_embedding = $row->embedding_vector
6213 - ? unserialize($row->embedding_vector, ['allowed_classes' => false])
6214 - : null;
6215 -
6216 - if (!is_array($database_embedding) || !is_array($user_embedding)) {
6217 - unset($database_embedding);
6218 - continue;
6219 - }
6220 -
3490 + // Get configuration options
3491 + $main_options = get_option('mxchat_options', []);
3492 +
3493 + // Get base similarity threshold (default 75%)
3494 + $similarity_threshold = isset($main_options['similarity_threshold'])
3495 + ? ((int) $main_options['similarity_threshold']) / 100
3496 + : 0.75;
3497 +
3498 + $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
3499 +
3500 + // Calculate similarities and build results array
3501 + $all_similarities = [];
3502 + $relevant_results = [];
3503 +
3504 + foreach ($embeddings as $embedding) {
3505 + $database_embedding = $embedding->embedding_vector
3506 + ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
3507 + : null;
3508 +
3509 + if (is_array($database_embedding) && is_array($user_embedding)) {
6221 3510 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
6222 - unset($database_embedding);
6223 -
6224 - $role_restriction = $row->role_restriction ?? 'public';
3511 +
3512 + // NEW: Check role access
3513 + $role_restriction = $embedding->role_restriction ?? 'public';
6225 3514 $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
6226 - $source_url = $row->source_url ?? '';
6227 -
6228 - // Maintain top 10 display buffer (insert-if-beats-worst)
6229 - if (count($top_display) < 10) {
6230 - $top_display[] = [
6231 - 'id' => $row->id,
6232 - 'similarity' => $similarity,
6233 - 'source_url' => $source_url,
6234 - 'role_restriction' => $role_restriction,
6235 - 'has_access' => $has_access,
6236 - ];
6237 - usort($top_display, function ($a, $b) {
6238 - return $b['similarity'] <=> $a['similarity'];
6239 - });
6240 - } elseif ($similarity > $top_display[9]['similarity']) {
6241 - $top_display[9] = [
6242 - 'id' => $row->id,
6243 - 'similarity' => $similarity,
6244 - 'source_url' => $source_url,
6245 - 'role_restriction' => $role_restriction,
6246 - 'has_access' => $has_access,
6247 - ];
6248 - usort($top_display, function ($a, $b) {
6249 - return $b['similarity'] <=> $a['similarity'];
6250 - });
3515 +
3516 + // Store ALL similarities for testing (top 10)
3517 + $source_display = '';
3518 + if (!empty($embedding->source_url) && $embedding->source_url !== '#') {
3519 + $source_display = $embedding->source_url;
3520 + } else {
3521 + $content_preview = strip_tags($embedding->article_content ?? '');
3522 + $content_preview = preg_replace('/\s+/', ' ', $content_preview);
3523 + $source_display = substr(trim($content_preview), 0, 50) . '...';
6251 3524 }
6252 -
6253 - // Record cosine for keyword-leg hits so fusion/display can anchor
6254 - // on the true similarity % even for below-threshold rescues.
6255 - if ($hybrid_enabled && isset($keyword_ids[$row->id])) {
6256 - $keyword_similarities[$row->id] = $similarity;
6257 - }
6258 -
6259 - // Track candidates for context assembly (above threshold + has access)
3525 +
3526 + $all_similarities[] = [
3527 + 'document_id' => $embedding->id,
3528 + 'similarity' => $similarity,
3529 + 'similarity_percentage' => round($similarity * 100, 2),
3530 + 'above_threshold' => $similarity >= $similarity_threshold,
3531 + 'source_display' => $source_display,
3532 + 'content_preview' => substr(strip_tags($embedding->article_content ?? ''), 0, 100) . '...',
3533 + 'used_for_context' => false, // Initialize as false, we'll update this later
3534 + 'role_restriction' => $role_restriction, // NEW: Include role info for testing
3535 + 'has_access' => $has_access, // NEW: Include access info for testing
3536 + 'filtered_out' => !$has_access // NEW: Mark if filtered out by role
3537 + ];
3538 +
3539 + // Only consider results above threshold AND with access for actual content retrieval
6260 3540 if ($similarity >= $similarity_threshold && $has_access) {
6261 - $candidates[] = [
6262 - 'id' => $row->id,
6263 - 'similarity' => $similarity,
6264 - 'source_url' => $source_url,
3541 + $relevant_results[] = [
3542 + 'id' => $embedding->id,
3543 + 'similarity' => $similarity
6265 3544 ];
6266 3545 }
6267 -
6268 - $total_checked++;
6269 3546 }
6270 -
6271 - unset($batch);
6272 -
6273 - // Trim candidates periodically to cap memory during long scans
6274 - if (count($candidates) > $max_candidates) {
6275 - usort($candidates, function ($a, $b) {
6276 - return $b['similarity'] <=> $a['similarity'];
6277 - });
6278 - $candidates = array_slice($candidates, 0, $max_candidates);
6279 - }
6280 -
6281 - $offset += $batch_size;
6282 - } while (true);
6283 -
6284 - if ($total_checked === 0) {
6285 - $this->current_valid_urls = [];
6286 - return '';
3547 +
3548 + unset($database_embedding);
6287 3549 }
6288 3550
6289 - // Final candidates sort (best first)
6290 - if (count($candidates) > 1) {
6291 - usort($candidates, function ($a, $b) {
6292 - return $b['similarity'] <=> $a['similarity'];
6293 - });
6294 - }
6295 -
6296 - // ===== HYBRID FUSION (plan-38ffa1) =====
6297 - // Reciprocal-rank fusion over the top-20 of each leg (k=60 standard).
6298 - // Rank-based, so the incomparable score scales (cosine 0-1 vs FULLTEXT
6299 - // relevance) never need calibrating. A below-threshold vector row can
6300 - // enter via a strong keyword rank — that is the point of the feature.
6301 - // Every candidate gets a 'rank_score' the downstream source ordering
6302 - // uses: with hybrid OFF it is exactly the cosine similarity, so the
6303 - // legacy path is byte-identical.
6304 - $fused_rank_map = array(); // id => 1-based fused rank
6305 - $matched_via_map = array(); // id => 'vector' | 'keyword' | 'both'
6306 - if (!$hybrid_enabled) {
6307 - foreach ($candidates as &$cand_ref) {
6308 - $cand_ref['rank_score'] = $cand_ref['similarity'];
6309 - }
6310 - unset($cand_ref);
6311 - } else {
6312 - $rrf_k = 60;
6313 - $fused = array();
6314 - foreach (array_slice($candidates, 0, 20) as $leg_rank => $cand) {
6315 - $fused[$cand['id']] = array(
6316 - 'id' => $cand['id'],
6317 - 'similarity' => $cand['similarity'],
6318 - 'source_url' => $cand['source_url'],
6319 - 'rrf' => 1 / ($rrf_k + $leg_rank + 1),
6320 - 'via' => 'vector',
6321 - );
6322 - }
6323 - foreach ($keyword_hits as $leg_rank => $hit) {
6324 - $rrf = 1 / ($rrf_k + $leg_rank + 1);
6325 - if (isset($fused[$hit['id']])) {
6326 - $fused[$hit['id']]['rrf'] += $rrf;
6327 - $fused[$hit['id']]['via'] = 'both';
6328 - } else {
6329 - $fused[$hit['id']] = array(
6330 - 'id' => $hit['id'],
6331 - 'similarity' => $keyword_similarities[$hit['id']] ?? 0.0,
6332 - 'source_url' => $hit['source_url'],
6333 - 'rrf' => $rrf,
6334 - 'via' => 'keyword',
6335 - );
6336 - }
6337 - }
6338 - uasort($fused, function ($a, $b) {
6339 - return $b['rrf'] <=> $a['rrf'];
6340 - });
6341 -
6342 - // Vector candidates beyond the top-20 leg keep flowing to the prompt
6343 - // builders after the fused block, in their vector order — the result
6344 - // count/shape downstream stays unchanged.
6345 - $tail = array_slice($candidates, 20);
6346 - $candidates = array();
6347 - $rank = 0;
6348 - foreach ($fused as $f) {
6349 - $rank++;
6350 - $fused_rank_map[$f['id']] = $rank;
6351 - $matched_via_map[$f['id']] = $f['via'];
6352 - $candidates[] = array(
6353 - 'id' => $f['id'],
6354 - 'similarity' => $f['similarity'],
6355 - 'source_url' => $f['source_url'],
6356 - 'rank_score' => $f['rrf'],
6357 - );
6358 - }
6359 - foreach ($tail as $cand) {
6360 - // Below any fused rrf (min possible fused rrf is 1/(60+40)=0.01;
6361 - // similarity * 1e-6 <= 1e-6), preserving relative vector order.
6362 - $cand['rank_score'] = $cand['similarity'] * 1e-6;
6363 - $candidates[] = $cand;
6364 - }
6365 - if (count($candidates) > $max_candidates) {
6366 - $candidates = array_slice($candidates, 0, $max_candidates);
6367 - }
6368 - }
6369 -
6370 - // ===== PHASE 2: FETCH ARTICLE CONTENT ONLY FOR WINNERS =====
6371 - // Gather unique IDs we actually need (top_display + candidates) and pull
6372 - // article_content in bounded IN() batches. This avoids loading content for
6373 - // every row during the similarity scan.
6374 - $needed_ids = [];
6375 - foreach ($top_display as $item) {
6376 - $needed_ids[$item['id']] = true;
6377 - }
6378 - foreach ($candidates as $item) {
6379 - $needed_ids[$item['id']] = true;
6380 - }
6381 - $needed_ids = array_keys($needed_ids);
6382 -
6383 - $content_map = [];
6384 - if (!empty($needed_ids)) {
6385 - foreach (array_chunk($needed_ids, 250) as $chunk_ids) {
6386 - $placeholders = implode(',', array_fill(0, count($chunk_ids), '%d'));
6387 - $rows = $wpdb->get_results($wpdb->prepare(
6388 - "SELECT id, article_content FROM {$system_prompt_table} WHERE id IN ($placeholders)",
6389 - ...$chunk_ids
6390 - ));
6391 - foreach ($rows as $r) {
6392 - $content_map[$r->id] = $r->article_content;
6393 - }
6394 - unset($rows);
6395 - }
6396 - }
6397 -
6398 - // Build the all_similarities display array from the top 10
6399 - $all_similarities = [];
6400 - foreach ($top_display as $item) {
6401 - $article_content_for_parse = $content_map[$item['id']] ?? '';
6402 - $parsed_for_display = MxChat_Chunker::parse_stored_chunk($article_content_for_parse);
6403 - $is_chunk = $parsed_for_display['is_chunked'];
6404 - $chunk_meta = $parsed_for_display['metadata'];
6405 -
6406 - if (!empty($item['source_url']) && $item['source_url'] !== '#') {
6407 - $source_display = $item['source_url'];
6408 - } else {
6409 - $content_preview = strip_tags($article_content_for_parse);
6410 - $content_preview = preg_replace('/\s+/', ' ', $content_preview);
6411 - $source_display = substr(trim($content_preview), 0, 50) . '...';
6412 - }
6413 -
6414 - $all_similarities[] = [
6415 - 'document_id' => $item['id'],
6416 - 'similarity' => $item['similarity'],
6417 - 'similarity_percentage' => round($item['similarity'] * 100, 2),
6418 - 'above_threshold' => $item['similarity'] >= $similarity_threshold,
6419 - 'source_display' => $source_display,
6420 - 'content_preview' => substr(strip_tags($parsed_for_display['text'] ?? ''), 0, 100) . '...',
6421 - 'used_for_context' => false,
6422 - 'role_restriction' => $item['role_restriction'],
6423 - 'has_access' => $item['has_access'],
6424 - 'filtered_out' => !$item['has_access'],
6425 - 'is_chunk' => $is_chunk,
6426 - 'chunk_index' => $is_chunk ? ($chunk_meta['chunk_index'] ?? 0) : null,
6427 - 'total_chunks' => $is_chunk ? ($chunk_meta['total_chunks'] ?? 1) : null
6428 - ];
6429 - }
6430 -
6431 - // Build url_groups from candidates for chunk reassembly
6432 - $url_groups = array();
6433 - foreach ($candidates as $cand) {
6434 - $article_content = $content_map[$cand['id']] ?? '';
6435 - $parsed = MxChat_Chunker::parse_stored_chunk($article_content);
6436 - $is_chunked = $parsed['is_chunked'];
6437 - $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6438 - $text_content = $parsed['text'];
6439 -
6440 - $source_url = $cand['source_url'];
6441 - $group_key = !empty($source_url) ? $source_url : '_manual_' . $cand['id'];
6442 -
6443 - if (!isset($url_groups[$group_key])) {
6444 - $url_groups[$group_key] = array(
6445 - 'source_url' => $source_url,
6446 - 'best_score' => 0,
6447 - 'is_chunked' => $is_chunked,
6448 - 'chunks' => array(),
6449 - 'single_text' => '',
6450 - 'single_id' => null
6451 - );
6452 - }
6453 -
6454 - // rank_score == similarity with hybrid off (byte-identical ordering);
6455 - // with hybrid on it carries the fused rank so keyword rescues sort up.
6456 - $cand_rank_score = $cand['rank_score'] ?? $cand['similarity'];
6457 - if ($cand_rank_score > $url_groups[$group_key]['best_score']) {
6458 - $url_groups[$group_key]['best_score'] = $cand_rank_score;
6459 - }
6460 -
6461 - if ($is_chunked) {
6462 - $url_groups[$group_key]['is_chunked'] = true;
6463 - $url_groups[$group_key]['chunks'][] = array(
6464 - 'id' => $cand['id'],
6465 - 'score' => $cand['similarity'],
6466 - 'chunk_index' => $chunk_index,
6467 - 'text' => $text_content
6468 - );
6469 - } else {
6470 - $url_groups[$group_key]['single_text'] = $text_content;
6471 - $url_groups[$group_key]['single_id'] = $cand['id'];
6472 - }
6473 - }
6474 -
6475 - // Hybrid display augmentation (plan-38ffa1, Maxwell's approval note):
6476 - // make sure every fused-top-10 row appears in the debug panel — a
6477 - // keyword-only rescue may sit below the vector top-10 buffer — and stamp
6478 - // matched_via + fused_rank on every row. Cosine % stays the anchor; no
6479 - // raw RRF numbers surface.
6480 - if ($hybrid_enabled) {
6481 - $displayed_ids = array();
6482 - foreach ($all_similarities as $disp_item) {
6483 - $displayed_ids[$disp_item['document_id']] = true;
6484 - }
6485 - $kw_info_by_id = array();
6486 - foreach ($keyword_hits as $hit) {
6487 - $kw_info_by_id[$hit['id']] = $hit;
6488 - }
6489 - foreach ($fused_rank_map as $fused_id => $fused_rank) {
6490 - if ($fused_rank > 10 || isset($displayed_ids[$fused_id])) {
6491 - continue;
6492 - }
6493 - $aug_content = $content_map[$fused_id] ?? '';
6494 - $aug_parsed = MxChat_Chunker::parse_stored_chunk($aug_content);
6495 - $aug_hit = $kw_info_by_id[$fused_id] ?? array();
6496 - $aug_similarity = $keyword_similarities[$fused_id] ?? 0.0;
6497 - $aug_source_url = $aug_hit['source_url'] ?? '';
6498 - if (!empty($aug_source_url) && $aug_source_url !== '#') {
6499 - $aug_source_display = $aug_source_url;
6500 - } else {
6501 - $aug_preview = preg_replace('/\s+/', ' ', strip_tags($aug_content));
6502 - $aug_source_display = substr(trim($aug_preview), 0, 50) . '...';
6503 - }
6504 - $all_similarities[] = [
6505 - 'document_id' => $fused_id,
6506 - 'similarity' => $aug_similarity,
6507 - 'similarity_percentage' => round($aug_similarity * 100, 2),
6508 - 'above_threshold' => $aug_similarity >= $similarity_threshold,
6509 - 'source_display' => $aug_source_display,
6510 - 'content_preview' => substr(strip_tags($aug_parsed['text'] ?? ''), 0, 100) . '...',
6511 - 'used_for_context' => false,
6512 - 'role_restriction' => $aug_hit['role_restriction'] ?? 'public',
6513 - 'has_access' => $aug_hit['has_access'] ?? true,
6514 - 'filtered_out' => false,
6515 - 'is_chunk' => $aug_parsed['is_chunked'],
6516 - 'chunk_index' => $aug_parsed['is_chunked'] ? ($aug_parsed['metadata']['chunk_index'] ?? 0) : null,
6517 - 'total_chunks' => $aug_parsed['is_chunked'] ? ($aug_parsed['metadata']['total_chunks'] ?? 1) : null,
6518 - ];
6519 - }
6520 - foreach ($all_similarities as &$disp_ref) {
6521 - $disp_ref['matched_via'] = $matched_via_map[$disp_ref['document_id']] ?? null;
6522 - $disp_ref['fused_rank'] = $fused_rank_map[$disp_ref['document_id']] ?? null;
6523 - }
6524 - unset($disp_ref);
6525 - }
6526 -
6527 - // Sort for the testing/debug display: fused rank when hybrid is on
6528 - // (nulls last, cosine as tie-break), raw similarity otherwise.
6529 - if ($hybrid_enabled) {
6530 - usort($all_similarities, function ($a, $b) {
6531 - $ar = $a['fused_rank'] ?? PHP_INT_MAX;
6532 - $br = $b['fused_rank'] ?? PHP_INT_MAX;
6533 - if ($ar !== $br) {
6534 - return $ar <=> $br;
6535 - }
6536 - return $b['similarity'] <=> $a['similarity'];
6537 - });
6538 - } else {
6539 - usort($all_similarities, function ($a, $b) {
6540 - return $b['similarity'] <=> $a['similarity'];
6541 - });
6542 - }
6543 -
6544 - // Sort URL groups by best score (highest first)
6545 - uasort($url_groups, function($a, $b) {
6546 - return $b['best_score'] <=> $a['best_score'];
3551 + // Sort ALL similarities for testing display (highest first)
3552 + usort($all_similarities, function ($a, $b) {
3553 + return $b['similarity'] <=> $a['similarity'];
6547 3554 });
6548 -
6549 - // Get RAG sources limit from options (default 6, min 3, max 10)
6550 - $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
6551 - if ($rag_sources_limit < 3) $rag_sources_limit = 3;
6552 - if ($rag_sources_limit > 10) $rag_sources_limit = 10;
6553 -
6554 - // Take top N unique URLs based on user setting
6555 - $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
6556 -
6557 - // Track which document IDs are used for context
3555 +
3556 + // Sort relevant results by similarity (highest first)
3557 + usort($relevant_results, function ($a, $b) {
3558 + return $b['similarity'] <=> $a['similarity'];
3559 + });
3560 +
3561 + // Get top 5 results for actual content (standard approach)
3562 + $top_results = array_slice($relevant_results, 0, 5);
3563 +
3564 + // NOW mark which documents are actually used for context
6558 3565 $used_document_ids = [];
6559 - foreach ($top_urls as $group) {
6560 - if ($group['is_chunked']) {
6561 - foreach ($group['chunks'] as $chunk) {
6562 - $used_document_ids[] = $chunk['id'];
6563 - }
6564 - } elseif ($group['single_id']) {
6565 - $used_document_ids[] = $group['single_id'];
6566 - }
3566 + foreach ($top_results as $result) {
3567 + $used_document_ids[] = $result['id'];
6567 3568 }
6568 -
3569 +
6569 3570 // Update the all_similarities array to mark which were actually used
6570 3571 foreach ($all_similarities as &$similarity_item) {
6571 3572 $similarity_item['used_for_context'] = in_array($similarity_item['document_id'], $used_document_ids);
6572 3573 }
6573 -
6574 - // Store top 10 for testing panel
3574 +
3575 + // Store top 10 for testing panel (now with correct used_for_context flags and role info)
6575 3576 $this->last_similarity_analysis['top_matches'] = array_slice($all_similarities, 0, 10);
6576 - $this->last_similarity_analysis['total_checked'] = $total_checked;
6577 -
3577 + $this->last_similarity_analysis['total_checked'] = count($embeddings);
3578 +
3579 + //error_log("MxChat Testing: Stored " . count($this->last_similarity_analysis['top_matches']) . " top matches for testing");
3580 +
6578 3581 // Initialize final content
6579 3582 $content = '';
6580 - $matches_used = 0;
6581 - $total_chunks_used = 0;
6582 - $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6583 - if ($max_total_chunks < 8) $max_total_chunks = 8;
6584 - if ($max_total_chunks > 20) $max_total_chunks = 20;
6585 - $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6586 -
6587 - // Check if citation links are enabled (default to 'on' for backwards compatibility)
6588 - // Use fresh options to ensure we get the latest setting value
6589 - $fresh_options = get_option('mxchat_options', []);
6590 - $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6591 -
6592 - // Build content from top sources
6593 - foreach ($top_urls as $group_key => $group) {
6594 - $source_url = $group['source_url']; // Use actual source_url, not the group key
6595 -
6596 - // Stop if we've hit the total chunk limit
6597 - if ($total_chunks_used >= $max_total_chunks) {
6598 - break;
3583 +
3584 + // Track document IDs to avoid duplicates
3585 + $added_document_ids = [];
3586 +
3587 + // Fetch and format content for each selected result
3588 + foreach ($top_results as $index => $result) {
3589 + if (in_array($result['id'], $added_document_ids)) {
3590 + continue;
6599 3591 }
6600 -
6601 - $full_text = '';
6602 - $chunks_in_this_source = 1; // Default for non-chunked content
6603 -
6604 - if ($group['is_chunked']) {
6605 - // Calculate how many chunks we can still use (respect both total and per-source caps)
6606 - $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
6607 -
6608 - // Fetch chunks for this URL with limit
6609 - $full_text = $this->reassemble_chunks_from_wordpress($source_url, $chunks_remaining, $chunks_in_this_source);
6610 -
6611 - // If fetching all chunks fails, fall back to matched chunks
6612 - if (empty($full_text)) {
6613 - // Sort matched chunks by index and concatenate
6614 - usort($group['chunks'], function($a, $b) {
6615 - return $a['chunk_index'] <=> $b['chunk_index'];
6616 - });
6617 -
6618 - $chunk_texts = array();
6619 - $chunks_in_this_source = 0;
6620 - foreach ($group['chunks'] as $chunk) {
6621 - if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
6622 - break;
6623 - }
6624 - $chunk_texts[] = $chunk['text'];
6625 - $chunks_in_this_source++;
3592 +
3593 + $chunk_content = $this->fetch_content_with_product_links($result['id']);
3594 + $added_document_ids[] = $result['id'];
3595 +
3596 + $content .= "## Reference " . ($index + 1) . " ##\n";
3597 + $content .= $chunk_content . "\n\n";
3598 +
3599 + // PDF surrounding pages logic (unchanged)
3600 + if (strpos($chunk_content, '{"document_type":"pdf"') !== false) {
3601 + $surrounding_content = $wpdb->get_results($wpdb->prepare(
3602 + "SELECT id, article_content, role_restriction FROM {$system_prompt_table}
3603 + WHERE id IN (
3604 + (SELECT id FROM {$system_prompt_table} WHERE id < %d ORDER BY id DESC LIMIT 1),
3605 + (SELECT id FROM {$system_prompt_table} WHERE id > %d ORDER BY id ASC LIMIT 1)
3606 + )",
3607 + $result['id'],
3608 + $result['id']
3609 + ));
3610 +
3611 + // NEW: Check role access for surrounding content too
3612 + if (!empty($surrounding_content[0])) {
3613 + $surrounding_role = $surrounding_content[0]->role_restriction ?? 'public';
3614 + if ($knowledge_manager->mxchat_user_has_content_access($surrounding_role)) {
3615 + $content .= "## Related Content ##\n";
3616 + $content .= $surrounding_content[0]->article_content . "\n\n";
3617 + $added_document_ids[] = $surrounding_content[0]->id;
6626 3618 }
6627 - $full_text = implode("\n\n", $chunk_texts);
6628 3619 }
6629 - } else {
6630 - $full_text = $group['single_text'];
6631 - $chunks_in_this_source = 1;
6632 - }
6633 -
6634 - if (!empty($full_text)) {
6635 - // Strip URLs from content if citation links are disabled
6636 - if (!$citation_links_enabled) {
6637 - $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
6638 - $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
6639 - }
6640 -
6641 - // Use numbered reference for URL-based entries, plain info label for manual entries
6642 - // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
6643 - if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
6644 - $matches_used++;
6645 - $content .= "## Reference " . $matches_used . " ##\n";
6646 - $content .= $full_text . "\n\n";
6647 -
6648 - // Only include citation URLs if citation links are enabled
6649 - if ($citation_links_enabled) {
6650 - $valid_urls[] = $source_url;
6651 - $content .= "URL: " . $source_url . "\n\n";
3620 +
3621 + if (!empty($surrounding_content[1])) {
3622 + $surrounding_role = $surrounding_content[1]->role_restriction ?? 'public';
3623 + if ($knowledge_manager->mxchat_user_has_content_access($surrounding_role)) {
3624 + $content .= "## Related Content ##\n";
3625 + $content .= $surrounding_content[1]->article_content . "\n\n";
3626 + $added_document_ids[] = $surrounding_content[1]->id;
6652 3627 }
6653 -
6654 - // Video-backed source → queue the consent-safe embed (03ba33)
6655 - $this->maybe_queue_youtube_embed($source_url, $full_text);
6656 - } else {
6657 - // Manual entry — no reference number, no citation
6658 - $content .= "## Information ##\n";
6659 - $content .= $full_text . "\n\n";
6660 3628 }
6661 -
6662 - // Extract any URLs from the text content itself (only if citation links enabled)
6663 - if ($citation_links_enabled) {
6664 - preg_match_all(
6665 - '#\bhttps?://[^\s<>"\']+#i',
6666 - $full_text,
6667 - $content_urls
6668 - );
6669 - if (!empty($content_urls[0])) {
6670 - $valid_urls = array_merge($valid_urls, $content_urls[0]);
6671 - }
6672 - }
6673 -
6674 - $total_chunks_used += $chunks_in_this_source;
6675 3629 }
6676 3630 }
6677 -
6678 - // NEW: Store unique valid URLs for validation
6679 - $this->current_valid_urls = array_unique($valid_urls);
6680 -
6681 - // Store sources and chunks counts for testing/transcript display
6682 - $this->last_similarity_analysis['sources_used'] = $matches_used;
6683 - $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
6684 -
6685 - // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
6686 - do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
6687 -
3631 +
6688 3632 // Add response guidelines
6689 - if (empty($top_urls)) {
6690 - // No matched sources: return empty so the prompt assembler's
6691 - // "NO RELEVANT CONTENT FOUND IN KNOWLEDGE DATABASE" branch fires —
6692 - // a no-info sentence wrapped in OFFICIAL KNOWLEDGE markers reads to
6693 - // the model as authoritative content (plan d7daf8).
6694 - $content = '';
3633 + if (empty($top_results)) {
3634 + $content = "No reference information was found for this query.\n\n";
6695 3635 } else {
6696 - // Build response guidelines based on citation links setting
6697 3636 $content .= "\n## Response Guidelines ##\n" .
6698 3637 "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
6699 3638 "Be conversational and friendly, but never mention your knowledge base or training data. " .
6700 3639 "If you don't have specific information or are uncertain about any details, it's always " .
6701 3640 "better to honestly say you don't know rather than making up or guessing at answers. " .
6702 - "When information is incomplete, let them know you are unsure.\n\n";
6703 -
6704 - // Only add hyperlink instructions if citation links are enabled
6705 - if ($citation_links_enabled) {
6706 - $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
6707 - "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
6708 - "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
6709 - } else {
6710 - $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
6711 - "Simply provide helpful answers based on the reference information without citing sources.";
6712 - }
3641 + "When information is incomplete, let them know you are unsure.";
6713 3642 }
6714 3643
6715 3644 return trim($content);
6716 3645 }
6717 3646
6718 -/**
6719 - * plan-mxchat-20260717-03ba33 — if a KB source used for context is a single
6720 - * YouTube video, queue ONE consent-safe embed for the response html channel.
6721 - * Called from BOTH retrieval builders (WordPress DB + Pinecone) inside their
6722 - * real-URL winner branch, in ranked order — so the first (best) video wins and
6723 - * later matches are ignored. Only KB/admin-ingested sources ever reach this
6724 - * point; a URL a visitor pastes in chat never does.
6725 - */
6726 -private function maybe_queue_youtube_embed($source_url, $full_text) {
6727 - if (!empty($this->videoEmbedHtml)) {
6728 - return; // one video per response
6729 - }
6730 - $video_id = MxChat_Utils::parse_youtube_id($source_url);
6731 - if (empty($video_id)) {
6732 - return;
6733 - }
6734 - // Ingestion writes "YouTube Video: {title}" / "Channel: {name}" / "URL: …"
6735 - // header lines into the indexed text. NOTE: when citation links are
6736 - // disabled the winner loop collapses ALL whitespace to single spaces
6737 - // before this runs, so the title must be terminated by the next header
6738 - // label, not by end-of-line. Fall back to a generic label when absent
6739 - // (e.g. a YouTube watch page imported through the plain URL source).
6740 - $title = '';
6741 - if (preg_match('/YouTube Video:\s*(.+?)(?=\s+Channel:\s|\s+URL:\s|\r|\n|$)/i', (string) $full_text, $m)) {
6742 - $title = trim(mb_substr(trim($m[1]), 0, 140));
6743 - if (preg_match('#^https?://#i', $title)) {
6744 - $title = ''; // header carried the URL, not a real title
6745 - }
6746 - }
6747 - $this->videoEmbedHtml = $this->build_youtube_embed_html($video_id, $title, $source_url);
6748 -}
6749 -
6750 -/**
6751 - * Consent-safe click-to-load YouTube facade. No Google iframe is created until
6752 - * the visitor taps play (chat-script.js swaps the facade for a
6753 - * youtube-nocookie.com iframe). The caption always carries a plain "Watch on
6754 - * YouTube" link, which is also the graceful degrade on strict-CSP sites where
6755 - * third-party frames are blocked.
6756 - */
6757 -private function build_youtube_embed_html($video_id, $title, $watch_url) {
6758 - $video_id = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $video_id);
6759 - if ($video_id === '') {
6760 - return '';
6761 - }
6762 - $thumb = 'https://i.ytimg.com/vi/' . $video_id . '/hqdefault.jpg';
6763 - $label = ($title !== '') ? $title : __('YouTube video', 'mxchat');
6764 -
6765 - $html = '<div class="mxchat-youtube-embed" data-video-id="' . esc_attr($video_id) . '">';
6766 - $html .= '<button type="button" class="mxchat-youtube-facade" aria-label="' . esc_attr(sprintf(__('Play video: %s', 'mxchat'), $label)) . '">';
6767 - $html .= '<img class="mxchat-youtube-thumb" src="' . esc_url($thumb) . '" alt="' . esc_attr($label) . '" loading="lazy" />';
6768 - $html .= '<span class="mxchat-youtube-play" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg></span>';
6769 - $html .= '</button>';
6770 - $html .= '<div class="mxchat-youtube-caption">';
6771 - $html .= '<span class="mxchat-youtube-title">' . esc_html($label) . '</span>';
6772 - $html .= '<a class="mxchat-youtube-link" href="' . esc_url($watch_url) . '" target="_blank" rel="noopener noreferrer">' . esc_html__('Watch on YouTube', 'mxchat') . '</a>';
6773 - $html .= '</div>';
6774 - $html .= '</div>';
6775 - return $html;
6776 -}
6777 -
6778 -/**
6779 - * Fetch and reassemble chunks for a URL from WordPress database
6780 - *
6781 - * @param string $source_url The source URL to fetch chunks for
6782 - * @param int $max_chunks Maximum number of chunks to return (0 = unlimited)
6783 - * @param int &$chunk_count Reference to store the actual number of chunks returned
6784 - * @return string Reassembled content from chunks
6785 - */
6786 -private function reassemble_chunks_from_wordpress($source_url, $max_chunks = 0, &$chunk_count = 0) {
6787 - global $wpdb;
6788 - $table = $wpdb->prefix . 'mxchat_system_prompt_content';
6789 -
6790 - // Fetch all rows with this source_url
6791 - $rows = $wpdb->get_results($wpdb->prepare(
6792 - "SELECT article_content FROM {$table}
6793 - WHERE source_url = %s
6794 - ORDER BY id ASC",
6795 - $source_url
6796 - ));
6797 -
6798 - if (empty($rows)) {
6799 - $chunk_count = 0;
6800 - return '';
6801 - }
6802 -
6803 - // Parse and sort chunks by index
6804 - $chunks = array();
6805 - foreach ($rows as $row) {
6806 - $parsed = MxChat_Chunker::parse_stored_chunk($row->article_content);
6807 -
6808 - if ($parsed['is_chunked']) {
6809 - $chunk_index = $parsed['metadata']['chunk_index'] ?? 0;
6810 - $chunks[$chunk_index] = $parsed['text'];
6811 - } else {
6812 - // Non-chunked content - just return it
6813 - $chunks[] = $parsed['text'];
6814 - }
6815 - }
6816 -
6817 - // Sort by chunk index
6818 - ksort($chunks);
6819 -
6820 - // Apply chunk limit if specified
6821 - if ($max_chunks > 0 && count($chunks) > $max_chunks) {
6822 - $chunks = array_slice($chunks, 0, $max_chunks, true);
6823 - }
6824 -
6825 - // Store actual chunk count
6826 - $chunk_count = count($chunks);
6827 -
6828 - // Reassemble content
6829 - return implode("\n\n", $chunks);
6830 -}
6831 -
6832 -private function find_relevant_content_pinecone($user_embedding, $bot_id = 'default', $bot_config = null) {
6833 - global $wpdb;
3647 +private function find_relevant_content_pinecone($user_embedding) {
3648 + global $wpdb; // For single role lookups
3649 + $options = get_option('mxchat_pinecone_addon_options', array());
3650 + $api_key = $options['mxchat_pinecone_api_key'] ?? '';
3651 + $host = $options['mxchat_pinecone_host'] ?? '';
6834 3652
6835 - //error_log("MXCHAT DEBUG: find_relevant_content_pinecone called");
6836 - //error_log(" - bot_id: " . $bot_id);
6837 - //error_log(" - user_embedding is array: " . (is_array($user_embedding) ? 'yes' : 'no'));
6838 - //error_log(" - user_embedding count: " . (is_array($user_embedding) ? count($user_embedding) : 'N/A'));
6839 -
6840 - // Use bot-specific config or fall back to default
6841 - if ($bot_config === null) {
6842 - $bot_config = $this->get_bot_pinecone_config($bot_id);
6843 - }
6844 -
6845 - $api_key = $bot_config['api_key'] ?? '';
6846 - $host = $bot_config['host'] ?? '';
6847 - $namespace = $bot_config['namespace'] ?? '';
6848 -
6849 - //error_log("MXCHAT DEBUG: Pinecone query parameters:");
6850 - //error_log(" - API Key: " . (empty($api_key) ? 'EMPTY - ERROR!' : 'Present (length: ' . strlen($api_key) . ')'));
6851 - //error_log(" - Host: " . (empty($host) ? 'EMPTY - ERROR!' : $host));
6852 - //error_log(" - Namespace: " . (empty($namespace) ? 'EMPTY (will use default)' : $namespace));
6853 -
6854 3653 // Initialize similarity analysis storage
6855 3654 $this->last_similarity_analysis = [
6856 3655 'knowledge_base_type' => 'Pinecone',
6857 - 'bot_id' => $bot_id,
6858 - 'namespace' => $namespace,
6859 3656 'top_matches' => [],
6860 3657 'threshold_used' => 0,
6861 3658 'total_checked' => 0
6862 3659 ];
6863 3660
6864 - // NEW: Initialize valid URLs array
6865 - $valid_urls = [];
6866 -
6867 3661 if (empty($host) || empty($api_key)) {
6868 - //error_log("MXCHAT DEBUG ERROR: Missing Pinecone host or API key!");
6869 - //error_log(" - Host empty: " . (empty($host) ? 'YES' : 'NO'));
6870 - //error_log(" - API key empty: " . (empty($api_key) ? 'YES' : 'NO'));
6871 - // Store empty array for valid URLs since we can't proceed
6872 - $this->current_valid_urls = [];
6873 3662 return '';
6874 3663 }
6875 3664
6876 3665 // Get knowledge manager instance for role checking
@@ -6875,37 +3664,26 @@
6875 3664
6876 3665 // Get knowledge manager instance for role checking
6877 3666 $knowledge_manager = MxChat_Knowledge_Manager::get_instance();
6878 3667
6879 - // Get the similarity threshold from the bot options or main options
6880 - $bot_options = $this->get_bot_options($bot_id);
6881 - $current_options = !empty($bot_options) ? $bot_options : get_option('mxchat_options', []);
3668 + // Get the similarity threshold from the main options
3669 + $main_options = get_option('mxchat_options', []);
3670 + $similarity_threshold = isset($main_options['similarity_threshold'])
3671 + ? ((int) $main_options['similarity_threshold']) / 100
3672 + : 0.75;
6882 3673
6883 - $similarity_threshold = isset($current_options['similarity_threshold'])
6884 - ? ((int) $current_options['similarity_threshold']) / 100
6885 - : 0.35;
6886 -
6887 3674 $this->last_similarity_analysis['threshold_used'] = $similarity_threshold;
6888 3675
6889 - // Prepare the query request for Pinecone
3676 + // Prepare the query request for Pinecone (request more for testing)
6890 3677 $api_endpoint = "https://{$host}/query";
6891 3678
6892 3679 $request_body = array(
6893 3680 'vector' => $user_embedding,
6894 - 'topK' => 50, // Increased for chunked content grouping - need more candidates to find top N unique URLs
3681 + 'topK' => 20, // Request more to get good testing data
6895 3682 'includeMetadata' => true,
6896 3683 'includeValues' => true
6897 3684 );
6898 3685
6899 - // Add namespace if specified for this bot
6900 - if (!empty($namespace)) {
6901 - $request_body['namespace'] = $namespace;
6902 - }
6903 -
6904 - //error_log("MXCHAT DEBUG: About to call Pinecone API");
6905 - //error_log(" - Endpoint: " . $api_endpoint);
6906 - //error_log(" - Namespace in request: " . (!empty($namespace) ? $namespace : 'NOT SET'));
6907 -
6908 3686 $response = wp_remote_post($api_endpoint, array(
6909 3687 'headers' => array(
6910 3688 'Api-Key' => $api_key,
6911 3689 'accept' => 'application/json',
@@ -6915,255 +3693,62 @@
6915 3693 'timeout' => 30
6916 3694 ));
6917 3695
6918 3696 if (is_wp_error($response)) {
6919 - //error_log("MXCHAT DEBUG ERROR: WP Error in Pinecone request: " . $response->get_error_message());
6920 - // Store empty array for valid URLs
6921 - $this->current_valid_urls = [];
6922 3697 return '';
6923 3698 }
6924 3699
6925 3700 $response_code = wp_remote_retrieve_response_code($response);
6926 - //error_log("MXCHAT DEBUG: Pinecone response code: " . $response_code);
6927 -
6928 3701 if ($response_code !== 200) {
6929 - $response_body = wp_remote_retrieve_body($response);
6930 - //error_log("MXCHAT DEBUG ERROR: Pinecone API error response: " . substr($response_body, 0, 500));
6931 - // Store empty array for valid URLs
6932 - $this->current_valid_urls = [];
6933 3702 return '';
6934 3703 }
6935 3704
6936 - // ADD DETAILED DEBUG SECTION HERE
6937 - $response_body = wp_remote_retrieve_body($response);
6938 - //error_log("MXCHAT DEBUG: Raw Pinecone response length: " . strlen($response_body));
6939 -
6940 - $results = json_decode($response_body, true);
6941 -
6942 - if (json_last_error() !== JSON_ERROR_NONE) {
6943 - //error_log("MXCHAT DEBUG ERROR: JSON decode error: " . json_last_error_msg());
6944 - //error_log("MXCHAT DEBUG: First 500 chars of response: " . substr($response_body, 0, 500));
6945 - // Store empty array for valid URLs
6946 - $this->current_valid_urls = [];
6947 - return '';
6948 - }
6949 -
6950 - //error_log("MXCHAT DEBUG: Pinecone response structure:");
6951 - //error_log(" - Has 'matches' key: " . (isset($results['matches']) ? 'yes' : 'no'));
6952 - //error_log(" - Has 'namespace' key: " . (isset($results['namespace']) ? 'yes (' . $results['namespace'] . ')' : 'no'));
6953 -
3705 + $results = json_decode(wp_remote_retrieve_body($response), true);
6954 3706 if (empty($results['matches'])) {
6955 - //error_log("MXCHAT DEBUG: No matches found in Pinecone response");
6956 - //error_log("MXCHAT DEBUG: Response keys: " . implode(', ', array_keys($results)));
6957 - // Store empty array for valid URLs
6958 - $this->current_valid_urls = [];
6959 3707 return '';
6960 3708 }
6961 3709
6962 - //error_log("MXCHAT DEBUG: Found " . count($results['matches']) . " matches in Pinecone");
6963 -
6964 - // Log first match details for debugging
6965 - if (!empty($results['matches'][0])) {
6966 - $first_match = $results['matches'][0];
6967 - //error_log("MXCHAT DEBUG: First match details:");
6968 - //error_log(" - Score: " . ($first_match['score'] ?? 'no score'));
6969 - //error_log(" - Has metadata: " . (isset($first_match['metadata']) ? 'yes' : 'no'));
6970 - if (isset($first_match['metadata'])) {
6971 - //error_log(" - Metadata keys: " . implode(', ', array_keys($first_match['metadata'])));
6972 - }
6973 - }
6974 -
6975 3710 // Initialize the final content
6976 3711 $content = '';
6977 3712 $matches_used = 0;
6978 3713 $matches_used_for_context = [];
6979 - $total_chunks_used = 0;
6980 - $max_total_chunks = isset($current_options['rag_chunks_limit']) ? intval($current_options['rag_chunks_limit']) : 15;
6981 - if ($max_total_chunks < 8) $max_total_chunks = 8;
6982 - if ($max_total_chunks > 20) $max_total_chunks = 20;
6983 - $max_chunks_per_source = 5; // Cap per individual source to limit token usage
6984 -
6985 - // Check if citation links are enabled (default to 'on' for backwards compatibility)
6986 - // Use fresh options to ensure we get the latest setting value
6987 - $fresh_options = get_option('mxchat_options', []);
6988 - $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
6989 -
6990 - // NEW CHUNKING LOGIC: Group results by source_url for chunk reassembly
6991 - $url_groups = array();
6992 -
3714 +
3715 + // Process each match for actual content generation (lazy role checking)
6993 3716 foreach ($results['matches'] as $index => $match) {
6994 3717 // Skip if similarity is below threshold
6995 3718 if ($match['score'] < $similarity_threshold) {
6996 3719 continue;
6997 3720 }
6998 -
6999 - $metadata = $match['metadata'] ?? array();
7000 - $source_url = $metadata['source_url'] ?? '';
7001 - $match_id = $match['id'] ?? '';
7002 -
7003 - // LAZY ROLE CHECK: Only check role for content we're actually considering
7004 - $role_restriction = $this->get_single_vector_role($match_id, $metadata);
7005 - $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
7006 -
7007 - // Skip if user doesn't have access
7008 - if (!$has_access) {
7009 - continue;
7010 - }
7011 -
7012 - // Use a unique key for manual entries without a source URL
7013 - $group_key = !empty($source_url) ? $source_url : '_manual_' . $match_id;
7014 -
7015 - // Group by source URL (or unique key for manual entries)
7016 - if (!isset($url_groups[$group_key])) {
7017 - $url_groups[$group_key] = array(
7018 - 'source_url' => $source_url,
7019 - 'best_score' => 0,
7020 - 'is_chunked' => isset($metadata['is_chunked']) && $metadata['is_chunked'],
7021 - 'chunks' => array(),
7022 - 'single_text' => ''
7023 - );
7024 - }
7025 -
7026 - // Track best score for this group
7027 - if ($match['score'] > $url_groups[$group_key]['best_score']) {
7028 - $url_groups[$group_key]['best_score'] = $match['score'];
7029 - }
7030 -
7031 - // Store chunk info or single text
7032 - if ($url_groups[$group_key]['is_chunked']) {
7033 - $url_groups[$group_key]['chunks'][] = array(
7034 - 'id' => $match_id,
7035 - 'score' => $match['score'],
7036 - 'chunk_index' => $metadata['chunk_index'] ?? 0,
7037 - 'text' => $metadata['text'] ?? ''
7038 - );
7039 - } else {
7040 - // Non-chunked content - just store the text
7041 - $url_groups[$group_key]['single_text'] = $metadata['text'] ?? '';
7042 - $url_groups[$group_key]['single_id'] = $match_id;
7043 - }
7044 - }
7045 -
7046 - // Sort URL groups by best score (highest first)
7047 - uasort($url_groups, function($a, $b) {
7048 - return $b['best_score'] <=> $a['best_score'];
7049 - });
7050 -
7051 - // Get RAG sources limit from options (default 6, min 3, max 10)
7052 - $rag_sources_limit = isset($current_options['rag_sources_limit']) ? intval($current_options['rag_sources_limit']) : 3;
7053 - if ($rag_sources_limit < 3) $rag_sources_limit = 3;
7054 - if ($rag_sources_limit > 10) $rag_sources_limit = 10;
7055 -
7056 - // Take top N unique URLs based on user setting
7057 - $top_urls = array_slice($url_groups, 0, $rag_sources_limit, true);
7058 -
7059 - // Track which match IDs are actually used for context
7060 - foreach ($top_urls as $group) {
7061 - if ($group['is_chunked']) {
7062 - foreach ($group['chunks'] as $chunk) {
7063 - $matches_used_for_context[] = $chunk['id'];
7064 - }
7065 - } elseif (!empty($group['single_id'])) {
7066 - $matches_used_for_context[] = $group['single_id'];
7067 - }
7068 - }
7069 -
7070 - // Build content from top sources
7071 - foreach ($top_urls as $group_key => $group) {
7072 - $source_url = $group['source_url']; // Use actual source_url, not the group key
7073 -
7074 - // Stop if we've hit the total chunk limit
7075 - if ($total_chunks_used >= $max_total_chunks) {
3721 +
3722 + // Limit to top 5 matches above threshold
3723 + if ($matches_used >= 5) {
7076 3724 break;
7077 3725 }
7078 -
7079 - $full_text = '';
7080 - $chunks_in_this_source = 1; // Default for non-chunked content
7081 -
7082 - if ($group['is_chunked']) {
7083 - // Calculate how many chunks we can still use (respect both total and per-source caps)
7084 - $chunks_remaining = min($max_chunks_per_source, $max_total_chunks - $total_chunks_used);
7085 -
7086 - // Fetch chunks for this URL with limit
7087 - $full_text = $this->reassemble_chunks_from_pinecone($source_url, $bot_config, $chunks_remaining, $chunks_in_this_source);
7088 -
7089 - // If fetching all chunks fails, fall back to matched chunks
7090 - if (empty($full_text)) {
7091 - // Sort matched chunks by index and concatenate
7092 - usort($group['chunks'], function($a, $b) {
7093 - return $a['chunk_index'] <=> $b['chunk_index'];
7094 - });
7095 -
7096 - $chunk_texts = array();
7097 - $chunks_in_this_source = 0;
7098 - foreach ($group['chunks'] as $chunk) {
7099 - if ($total_chunks_used + $chunks_in_this_source >= $max_total_chunks) {
7100 - break;
7101 - }
7102 - $chunk_texts[] = $chunk['text'];
7103 - $chunks_in_this_source++;
7104 - }
7105 - $full_text = implode("\n\n", $chunk_texts);
3726 +
3727 + if (!empty($match['metadata']['text'])) {
3728 + // LAZY ROLE CHECK: Only check role for content we're actually considering
3729 + $match_id = $match['id'] ?? '';
3730 + $role_restriction = $this->get_single_vector_role($match_id, $match['metadata']);
3731 + $has_access = $knowledge_manager->mxchat_user_has_content_access($role_restriction);
3732 +
3733 + // Skip if user doesn't have access
3734 + if (!$has_access) {
3735 + continue;
7106 3736 }
7107 - } else {
7108 - $full_text = $group['single_text'];
7109 - $chunks_in_this_source = 1;
7110 - }
7111 -
7112 - if (!empty($full_text)) {
7113 - // Strip URLs from content if citation links are disabled
7114 - if (!$citation_links_enabled) {
7115 - $full_text = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $full_text);
7116 - $full_text = preg_replace('/\s+/', ' ', trim($full_text)); // Clean up extra spaces
3737 +
3738 + // User has access - add to content
3739 + $content .= "## Reference " . ($matches_used + 1) . " ##\n";
3740 + $content .= $match['metadata']['text'] . "\n\n";
3741 +
3742 + if (!empty($match['metadata']['source_url'])) {
3743 + $content .= "URL: " . $match['metadata']['source_url'] . "\n\n";
7117 3744 }
7118 -
7119 - // Use numbered reference for URL-based entries, plain info label for manual entries
7120 - // Manual entries are stored with an internal mxchat:// placeholder URL — never expose them as citations
7121 - if (!empty($source_url) && $source_url !== '#' && strpos($source_url, 'mxchat://') !== 0) {
7122 - $matches_used++;
7123 - $content .= "## Reference " . $matches_used . " ##\n";
7124 - $content .= $full_text . "\n\n";
7125 -
7126 - // Only include citation URLs if citation links are enabled
7127 - if ($citation_links_enabled) {
7128 - $valid_urls[] = $source_url;
7129 - $content .= "URL: " . $source_url . "\n\n";
7130 - }
7131 -
7132 - // Video-backed source → queue the consent-safe embed (03ba33)
7133 - $this->maybe_queue_youtube_embed($source_url, $full_text);
7134 - } else {
7135 - // Manual entry — no reference number, no citation. Count it as a USED
7136 - // source (plan-mxchat-20260622-c1fe6a): without this, manual/Direct-Content
7137 - // entries (empty or mxchat:// source_url) never increment $matches_used, so
7138 - // the gate below (`if ($matches_used === 0)`) discards manual-only context on
7139 - // the Pinecone backend and the model is told "No reference information was
7140 - // found" — even though the testing panel reports used_for_context:true. It
7141 - // also corrects the cosmetic sources_used:0 the panel/transcript showed. The
7142 - // sibling local/WP-DB builder gates on empty($top_urls), so it never had this
7143 - // bug; this brings Pinecone to parity. Manual entries are still uncited (not
7144 - // added to $valid_urls, no "URL:" line).
7145 - $matches_used++;
7146 - $content .= "## Information ##\n";
7147 - $content .= $full_text . "\n\n";
7148 - }
7149 -
7150 - // Extract any URLs from the text content itself (only if citation links enabled)
7151 - if ($citation_links_enabled) {
7152 - preg_match_all(
7153 - '#\bhttps?://[^\s<>"\']+#i',
7154 - $full_text,
7155 - $content_urls
7156 - );
7157 - if (!empty($content_urls[0])) {
7158 - $valid_urls = array_merge($valid_urls, $content_urls[0]);
7159 - }
7160 - }
7161 -
7162 - $total_chunks_used += $chunks_in_this_source;
3745 +
3746 + $matches_used_for_context[] = $match['id'] ?? $index;
3747 + $matches_used++;
7163 3748 }
7164 3749 }
7165 -
3750 +
7166 3751 // Process ALL matches for testing data (top 10) - with role checking for testing display
7167 3752 $all_matches = [];
7168 3753 foreach ($results['matches'] as $index => $match) {
7169 3754 if ($index >= 10) break; // Limit to top 10 for testing
@@ -7183,19 +3768,9 @@
7183 3768 $source_display = substr(trim($content_preview), 0, 50) . '...';
7184 3769 }
7185 3770
7186 3771 $match_id_for_display = $match['id'] ?? $index;
7187 -
7188 - // Check for chunk metadata in Pinecone
7189 - $is_chunk = isset($match['metadata']['is_chunked']) && $match['metadata']['is_chunked'];
7190 - $chunk_index = isset($match['metadata']['chunk_index']) ? intval($match['metadata']['chunk_index']) : null;
7191 - $total_chunks = isset($match['metadata']['total_chunks']) ? intval($match['metadata']['total_chunks']) : null;
7192 -
7193 - // Also detect chunk from vector ID pattern: {hash}_chunk_{index}
7194 - if (!$is_chunk && MxChat_Chunker::is_chunk_vector_id($match_id_for_display)) {
7195 - $is_chunk = true;
7196 - }
7197 -
3772 +
7198 3773 $all_matches[] = [
7199 3774 'document_id' => $match_id_for_display,
7200 3775 'similarity' => $match['score'],
7201 3776 'similarity_percentage' => round($match['score'] * 100, 2),
@@ -7204,12 +3779,9 @@
7204 3779 'content_preview' => substr(strip_tags($match['metadata']['text'] ?? ''), 0, 100) . '...',
7205 3780 'used_for_context' => in_array($match_id_for_display, $matches_used_for_context),
7206 3781 'role_restriction' => $role_restriction,
7207 3782 'has_access' => $has_access,
7208 - 'filtered_out' => !$has_access,
7209 - 'is_chunk' => $is_chunk,
7210 - 'chunk_index' => $chunk_index,
7211 - 'total_chunks' => $total_chunks
3783 + 'filtered_out' => !$has_access
7212 3784 ];
7213 3785 }
7214 3786
7215 3787 // Store for testing panel
@@ -7214,41 +3786,23 @@
7214 3786
7215 3787 // Store for testing panel
7216 3788 $this->last_similarity_analysis['top_matches'] = $all_matches;
7217 3789 $this->last_similarity_analysis['total_checked'] = count($results['matches']);
7218 - $this->last_similarity_analysis['sources_used'] = $matches_used;
7219 - $this->last_similarity_analysis['total_chunks_used'] = $total_chunks_used;
7220 -
7221 - // NEW: Store unique valid URLs for validation
7222 - $this->current_valid_urls = array_unique($valid_urls);
7223 -
7224 - // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7225 - do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7226 -
3790 +
3791 + //error_log("MxChat Testing: Stored " . count($this->last_similarity_analysis['top_matches']) . " Pinecone matches for testing");
3792 +
7227 3793 // Add response guidelines
7228 3794 if ($matches_used === 0) {
7229 - // Empty return → assembler's NO RELEVANT CONTENT branch (plan d7daf8).
7230 - $content = '';
3795 + $content = "No reference information was found for this query.\n\n";
7231 3796 } else {
7232 - // Build response guidelines based on citation links setting
7233 - $content .= "\n## Response Guidelines ##\n" .
7234 - "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7235 - "Be conversational and friendly, but never mention your knowledge base or training data. " .
7236 - "If you don't have specific information or are uncertain about any details, it's always " .
7237 - "better to honestly say you don't know rather than making up or guessing at answers. " .
7238 - "When information is incomplete, let them know you are unsure.\n\n";
7239 -
7240 - // Only add hyperlink instructions if citation links are enabled
7241 - if ($citation_links_enabled) {
7242 - $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7243 - "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about. " .
7244 - "Only cite references that have a URL. Do not cite or add source labels to Information sections that have no URL.";
7245 - } else {
7246 - $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7247 - "Simply provide helpful answers based on the reference information without citing sources.";
7248 - }
3797 + $content .= "\n## Response Guidelines ##\n" .
3798 + "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
3799 + "Be conversational and friendly, but never mention your knowledge base or training data. " .
3800 + "If you don't have specific information or are uncertain about any details, it's always " .
3801 + "better to honestly say you don't know rather than making up or guessing at answers. " .
3802 + "When information is incomplete, let them know you are unsure.";
7249 3803 }
7250 -
3804 +
7251 3805 return trim($content);
7252 3806 }
7253 3807
7254 3808 /**
@@ -7288,524 +3842,12 @@
7288 3842 }
7289 3843
7290 3844 // Cache individual role for 1 hour
7291 3845 wp_cache_set($cache_key, $role_restriction, 'mxchat_vector_roles', 3600);
7292 -
3846 +
7293 3847 return $role_restriction;
7294 3848 }
7295 3849
7296 -/**
7297 - * Fetch and reassemble all chunks for a URL from Pinecone
7298 - *
7299 - * @param string $source_url The source URL to fetch chunks for
7300 - * @param array $bot_config Bot-specific Pinecone configuration
7301 - * @return string Reassembled content from all chunks
7302 - */
7303 -private function reassemble_chunks_from_pinecone($source_url, $bot_config, $max_chunks = 0, &$chunk_count = 0) {
7304 - $api_key = $bot_config['api_key'] ?? '';
7305 - $host = $bot_config['host'] ?? '';
7306 - $namespace = $bot_config['namespace'] ?? '';
7307 -
7308 - if (empty($host) || empty($api_key)) {
7309 - $chunk_count = 0;
7310 - return '';
7311 - }
7312 -
7313 - $base_hash = md5($source_url);
7314 -
7315 - // Use Pinecone list API to find all chunk vectors with this prefix
7316 - $list_url = "https://{$host}/vectors/list";
7317 -
7318 - // Limit to max_chunks if specified, otherwise fetch up to 100
7319 - $fetch_limit = ($max_chunks > 0 && $max_chunks < 100) ? $max_chunks : 100;
7320 -
7321 - $list_body = array(
7322 - 'prefix' => $base_hash . '_chunk_',
7323 - 'limit' => $fetch_limit
7324 - );
7325 -
7326 - if (!empty($namespace)) {
7327 - $list_body['namespace'] = $namespace;
7328 - }
7329 -
7330 - $list_response = wp_remote_post($list_url, array(
7331 - 'headers' => array(
7332 - 'Api-Key' => $api_key,
7333 - 'accept' => 'application/json',
7334 - 'content-type' => 'application/json'
7335 - ),
7336 - 'body' => wp_json_encode($list_body),
7337 - 'timeout' => 30
7338 - ));
7339 -
7340 - if (is_wp_error($list_response)) {
7341 - //error_log('[MXCHAT-CHUNK] List API error: ' . $list_response->get_error_message());
7342 - return '';
7343 - }
7344 -
7345 - $list_data = json_decode(wp_remote_retrieve_body($list_response), true);
7346 -
7347 - if (empty($list_data['vectors'])) {
7348 - //error_log('[MXCHAT-CHUNK] No chunk vectors found for URL: ' . $source_url);
7349 - return '';
7350 - }
7351 -
7352 - // Extract vector IDs
7353 - $vector_ids = array();
7354 - foreach ($list_data['vectors'] as $vector) {
7355 - if (isset($vector['id'])) {
7356 - $vector_ids[] = $vector['id'];
7357 - }
7358 - }
7359 -
7360 - if (empty($vector_ids)) {
7361 - return '';
7362 - }
7363 -
7364 - // Fetch all chunk content
7365 - $fetch_url = "https://{$host}/vectors/fetch";
7366 -
7367 - $fetch_body = array(
7368 - 'ids' => $vector_ids
7369 - );
7370 -
7371 - if (!empty($namespace)) {
7372 - $fetch_body['namespace'] = $namespace;
7373 - }
7374 -
7375 - $fetch_response = wp_remote_post($fetch_url, array(
7376 - 'headers' => array(
7377 - 'Api-Key' => $api_key,
7378 - 'accept' => 'application/json',
7379 - 'content-type' => 'application/json'
7380 - ),
7381 - 'body' => wp_json_encode($fetch_body),
7382 - 'timeout' => 30
7383 - ));
7384 -
7385 - if (is_wp_error($fetch_response)) {
7386 - //error_log('[MXCHAT-CHUNK] Fetch API error: ' . $fetch_response->get_error_message());
7387 - return '';
7388 - }
7389 -
7390 - $fetch_data = json_decode(wp_remote_retrieve_body($fetch_response), true);
7391 -
7392 - if (empty($fetch_data['vectors'])) {
7393 - return '';
7394 - }
7395 -
7396 - // Sort chunks by index and reassemble
7397 - $chunks = array();
7398 - foreach ($fetch_data['vectors'] as $id => $vector) {
7399 - $metadata = $vector['metadata'] ?? array();
7400 - $chunk_index = $metadata['chunk_index'] ?? 0;
7401 - $text = $metadata['text'] ?? '';
7402 -
7403 - // Store chunk with its index
7404 - $chunks[$chunk_index] = $text;
7405 - }
7406 -
7407 - // Sort by chunk index
7408 - ksort($chunks);
7409 -
7410 - // Apply chunk limit if specified
7411 - if ($max_chunks > 0 && count($chunks) > $max_chunks) {
7412 - $chunks = array_slice($chunks, 0, $max_chunks, true);
7413 - }
7414 -
7415 - // Store actual chunk count
7416 - $chunk_count = count($chunks);
7417 -
7418 - // Reassemble content
7419 - return implode("\n\n", $chunks);
7420 -}
7421 -
7422 -/**
7423 - * Search for relevant content using OpenAI Vector Store (File Search)
7424 - *
7425 - * @param string $user_query The user's query text
7426 - * @param string $bot_id The bot ID
7427 - * @param array $vectorstore_config Vector Store configuration
7428 - * @return string Formatted context string with references
7429 - */
7430 -private function find_relevant_content_openai_vectorstore($user_query, $bot_id = 'default', $vectorstore_config = array()) {
7431 - //error_log("MXCHAT DEBUG: find_relevant_content_openai_vectorstore called");
7432 - //error_log(" - bot_id: " . $bot_id);
7433 - //error_log(" - user_query length: " . strlen($user_query));
7434 -
7435 - // Get OpenAI API key
7436 - $mxchat_options = get_option('mxchat_options', array());
7437 - $api_key = $mxchat_options['api_key'] ?? '';
7438 -
7439 - // Reset vectorstore error tracking
7440 - $this->last_vectorstore_error = null;
7441 -
7442 - if (empty($api_key)) {
7443 - //error_log("MXCHAT DEBUG ERROR: OpenAI API key not configured");
7444 - $this->last_vectorstore_error = 'Vector Store search failed: OpenAI API key is not configured.';
7445 - $this->current_valid_urls = [];
7446 - return '';
7447 - }
7448 -
7449 - // Get Vector Store configuration
7450 - if (empty($vectorstore_config)) {
7451 - $vectorstore_config = $this->get_bot_vectorstore_config($bot_id);
7452 - }
7453 -
7454 - $vectorstore_ids_string = $vectorstore_config['vectorstore_ids'] ?? '';
7455 - $max_results = $vectorstore_config['max_results'] ?? 5;
7456 -
7457 - if (empty($vectorstore_ids_string)) {
7458 - //error_log("MXCHAT DEBUG ERROR: No Vector Store IDs configured");
7459 - $this->last_vectorstore_error = 'Vector Store search failed: No Vector Store IDs are configured for this bot.';
7460 - $this->current_valid_urls = [];
7461 - return '';
7462 - }
7463 -
7464 - // Parse Vector Store IDs
7465 - $vectorstore_ids = array_map('trim', explode(',', $vectorstore_ids_string));
7466 - $vectorstore_ids = array_filter($vectorstore_ids); // Remove empty values
7467 -
7468 - //error_log("MXCHAT DEBUG: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7469 - //error_log("MXCHAT DEBUG: Max results: " . $max_results);
7470 -
7471 - // Initialize similarity analysis storage
7472 - $this->last_similarity_analysis = [
7473 - 'knowledge_base_type' => 'OpenAI Vector Store',
7474 - 'bot_id' => $bot_id,
7475 - 'vectorstore_ids' => $vectorstore_ids,
7476 - 'top_matches' => [],
7477 - 'threshold_used' => 0,
7478 - 'total_checked' => 0
7479 - ];
7480 -
7481 - $valid_urls = [];
7482 -
7483 - // Get the selected model
7484 - $bot_options = $this->get_bot_options($bot_id);
7485 - $current_options = !empty($bot_options) ? $bot_options : $mxchat_options;
7486 - $selected_model = $current_options['model'] ?? 'gpt-5.6-sol';
7487 -
7488 - // Verify it's an OpenAI model
7489 - if (!$this->is_openai_chat_model($selected_model)) {
7490 - //error_log("MXCHAT DEBUG ERROR: Vector Store search requires OpenAI model. Current: " . $selected_model);
7491 - $this->last_vectorstore_error = 'Vector Store search requires an OpenAI model. Current model: ' . $selected_model;
7492 - $this->current_valid_urls = [];
7493 - return '';
7494 - }
7495 -
7496 - // Use OpenAI Responses API with file_search tool
7497 - $request_body = array(
7498 - 'model' => $selected_model,
7499 - 'input' => $user_query,
7500 - 'tools' => array(
7501 - array(
7502 - 'type' => 'file_search',
7503 - 'vector_store_ids' => $vectorstore_ids,
7504 - 'max_num_results' => intval($max_results)
7505 - )
7506 - ),
7507 - 'include' => array('output[*].file_search_call.search_results')
7508 - );
7509 -
7510 - //error_log("MXCHAT VECTORSTORE: ========== REQUEST START ==========");
7511 - //error_log("MXCHAT VECTORSTORE: Model: " . $selected_model);
7512 - //error_log("MXCHAT VECTORSTORE: Query: " . substr($user_query, 0, 200));
7513 - //error_log("MXCHAT VECTORSTORE: Vector Store IDs: " . implode(', ', $vectorstore_ids));
7514 - //error_log("MXCHAT VECTORSTORE: Max Results: " . $max_results);
7515 - //error_log("MXCHAT VECTORSTORE: Request body: " . wp_json_encode($request_body));
7516 -
7517 - $response = wp_remote_post('https://api.openai.com/v1/responses', array(
7518 - 'headers' => array(
7519 - 'Authorization' => 'Bearer ' . $api_key,
7520 - 'Content-Type' => 'application/json'
7521 - ),
7522 - 'body' => wp_json_encode($request_body),
7523 - 'timeout' => 60
7524 - ));
7525 -
7526 - if (is_wp_error($response)) {
7527 - //error_log("MXCHAT VECTORSTORE ERROR: WP Error: " . $response->get_error_message());
7528 - $this->last_vectorstore_error = 'Vector Store API request failed: ' . $response->get_error_message();
7529 - $this->current_valid_urls = [];
7530 - return '';
7531 - }
7532 -
7533 - $response_code = wp_remote_retrieve_response_code($response);
7534 - //error_log("MXCHAT VECTORSTORE: Response code: " . $response_code);
7535 -
7536 - $response_body = wp_remote_retrieve_body($response);
7537 - //error_log("MXCHAT VECTORSTORE: Raw response (first 2000 chars): " . substr($response_body, 0, 2000));
7538 -
7539 - if ($response_code !== 200) {
7540 - //error_log("MXCHAT VECTORSTORE ERROR: API error response: " . $response_body);
7541 - $decoded_error = json_decode($response_body, true);
7542 - $api_error_detail = $this->extract_provider_error($decoded_error, '');
7543 - $this->last_vectorstore_error = 'Vector Store API returned HTTP ' . $response_code . ($api_error_detail ? ': ' . $api_error_detail : '');
7544 - $this->current_valid_urls = [];
7545 - return '';
7546 - }
7547 - $result = json_decode($response_body, true);
7548 -
7549 - if (json_last_error() !== JSON_ERROR_NONE) {
7550 - //error_log("MXCHAT VECTORSTORE ERROR: JSON decode error: " . json_last_error_msg());
7551 - $this->last_vectorstore_error = 'Vector Store response could not be parsed: ' . json_last_error_msg();
7552 - $this->current_valid_urls = [];
7553 - return '';
7554 - }
7555 -
7556 - // Debug: Log the structure of the result
7557 - //error_log("MXCHAT VECTORSTORE: Result keys: " . implode(', ', array_keys($result)));
7558 - if (isset($result['output'])) {
7559 - //error_log("MXCHAT VECTORSTORE: Output count: " . count($result['output']));
7560 - foreach ($result['output'] as $idx => $out) {
7561 - //error_log("MXCHAT VECTORSTORE: Output[$idx] type: " . ($out['type'] ?? 'unknown'));
7562 - //error_log("MXCHAT VECTORSTORE: Output[$idx] keys: " . implode(', ', array_keys($out)));
7563 - }
7564 - } else {
7565 - //error_log("MXCHAT VECTORSTORE: No 'output' key in result!");
7566 - }
7567 -
7568 - // Extract file search results from the response
7569 - $content = '';
7570 - $matches_used = 0;
7571 - $all_matches = [];
7572 -
7573 - // The Responses API returns output array with tool results
7574 - if (isset($result['output']) && is_array($result['output'])) {
7575 - foreach ($result['output'] as $output_item) {
7576 - // Look for file_search_call results
7577 - if (isset($output_item['type']) && $output_item['type'] === 'file_search_call') {
7578 - //error_log("MXCHAT VECTORSTORE: Found file_search_call output item");
7579 - //error_log("MXCHAT VECTORSTORE: file_search_call keys: " . implode(', ', array_keys($output_item)));
7580 -
7581 - // Check for search_results in the output item directly
7582 - $search_results = $output_item['search_results'] ?? $output_item['results'] ?? [];
7583 - //error_log("MXCHAT VECTORSTORE: Search results count: " . count($search_results));
7584 -
7585 - if (empty($search_results)) {
7586 - //error_log("MXCHAT VECTORSTORE: No search results found in file_search_call");
7587 - //error_log("MXCHAT VECTORSTORE: file_search_call content: " . wp_json_encode($output_item));
7588 - }
7589 -
7590 - foreach ($search_results as $index => $search_result) {
7591 - $filename = $search_result['filename'] ?? '';
7592 - $score = $search_result['score'] ?? 0;
7593 - $text_content = '';
7594 -
7595 - // Extract text content from the result
7596 - // The text can be directly on the result OR nested under content array
7597 - if (isset($search_result['text']) && !empty($search_result['text'])) {
7598 - // Direct text field (OpenAI's actual format)
7599 - $text_content = $search_result['text'];
7600 - //error_log("MXCHAT VECTORSTORE: Found text directly on result[$index], length: " . strlen($text_content));
7601 - } elseif (isset($search_result['content']) && is_array($search_result['content'])) {
7602 - // Nested content array format
7603 - foreach ($search_result['content'] as $content_item) {
7604 - if (isset($content_item['text'])) {
7605 - $text_content .= $content_item['text'] . "\n";
7606 - }
7607 - }
7608 - //error_log("MXCHAT VECTORSTORE: Found text in content array for result[$index], length: " . strlen($text_content));
7609 - } else {
7610 - //error_log("MXCHAT VECTORSTORE: No text found for result[$index]. Keys: " . implode(', ', array_keys($search_result)));
7611 - }
7612 -
7613 - if (!empty($text_content)) {
7614 - $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7615 - $content .= trim($text_content) . "\n\n";
7616 -
7617 - if (!empty($filename)) {
7618 - $content .= "Source: " . $filename . "\n\n";
7619 - }
7620 -
7621 - // Extract URLs from content
7622 - preg_match_all(
7623 - '#\bhttps?://[^\s<>"\']+#i',
7624 - $text_content,
7625 - $content_urls
7626 - );
7627 - if (!empty($content_urls[0])) {
7628 - $valid_urls = array_merge($valid_urls, $content_urls[0]);
7629 - }
7630 -
7631 - $matches_used++;
7632 - }
7633 -
7634 - // Store for similarity analysis
7635 - $all_matches[] = [
7636 - 'document_id' => $filename ?: ('result_' . $index),
7637 - 'similarity' => $score,
7638 - 'similarity_percentage' => round($score * 100, 2),
7639 - 'above_threshold' => true,
7640 - 'source_display' => $filename,
7641 - 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7642 - 'used_for_context' => true,
7643 - 'role_restriction' => 'public',
7644 - 'has_access' => true,
7645 - 'filtered_out' => false
7646 - ];
7647 - }
7648 - }
7649 -
7650 - // Also check for message content with annotations (citations)
7651 - if (isset($output_item['type']) && $output_item['type'] === 'message') {
7652 - if (isset($output_item['content']) && is_array($output_item['content'])) {
7653 - foreach ($output_item['content'] as $content_block) {
7654 - if (isset($content_block['annotations']) && is_array($content_block['annotations'])) {
7655 - foreach ($content_block['annotations'] as $annotation) {
7656 - if (isset($annotation['filename'])) {
7657 - $filename = $annotation['filename'];
7658 - $score = $annotation['score'] ?? 0;
7659 - $text_content = '';
7660 -
7661 - if (isset($annotation['content']) && is_array($annotation['content'])) {
7662 - foreach ($annotation['content'] as $ann_content) {
7663 - if (isset($ann_content['text'])) {
7664 - $text_content .= $ann_content['text'] . "\n";
7665 - }
7666 - }
7667 - }
7668 -
7669 - if (!empty($text_content) && $matches_used < $max_results) {
7670 - $content .= "## Reference " . ($matches_used + 1) . " ##\n";
7671 - $content .= trim($text_content) . "\n\n";
7672 - $content .= "Source: " . $filename . "\n\n";
7673 -
7674 - preg_match_all(
7675 - '#\bhttps?://[^\s<>"\']+#i',
7676 - $text_content,
7677 - $content_urls
7678 - );
7679 - if (!empty($content_urls[0])) {
7680 - $valid_urls = array_merge($valid_urls, $content_urls[0]);
7681 - }
7682 -
7683 - $matches_used++;
7684 -
7685 - $all_matches[] = [
7686 - 'document_id' => $filename,
7687 - 'similarity' => $score,
7688 - 'similarity_percentage' => round($score * 100, 2),
7689 - 'above_threshold' => true,
7690 - 'source_display' => $filename,
7691 - 'content_preview' => substr(strip_tags($text_content), 0, 100) . '...',
7692 - 'used_for_context' => true,
7693 - 'role_restriction' => 'public',
7694 - 'has_access' => true,
7695 - 'filtered_out' => false
7696 - ];
7697 - }
7698 - }
7699 - }
7700 - }
7701 - }
7702 - }
7703 - }
7704 - }
7705 - }
7706 -
7707 - // Store for testing panel
7708 - $this->last_similarity_analysis['top_matches'] = $all_matches;
7709 - $this->last_similarity_analysis['total_checked'] = count($all_matches);
7710 -
7711 - // Store unique valid URLs for validation
7712 - $this->current_valid_urls = array_unique($valid_urls);
7713 -
7714 - // Allow add-ons to act on similarity results (e.g. WooCommerce product card display)
7715 - do_action('mxchat_similarity_results', $this->last_similarity_analysis['top_matches'], $bot_id);
7716 -
7717 - //error_log("MXCHAT VECTORSTORE: ========== SEARCH COMPLETE ==========");
7718 - //error_log("MXCHAT VECTORSTORE: Matches used: " . $matches_used);
7719 - //error_log("MXCHAT VECTORSTORE: All matches count: " . count($all_matches));
7720 - //error_log("MXCHAT VECTORSTORE: Content length: " . strlen($content));
7721 - if ($matches_used > 0) {
7722 - //error_log("MXCHAT VECTORSTORE: Content preview: " . substr($content, 0, 500));
7723 - }
7724 -
7725 - // Check if citation links are enabled
7726 - $citation_links_enabled = ($mxchat_options['citation_links_toggle'] ?? 'on') === 'on';
7727 -
7728 - // Add response guidelines
7729 - if ($matches_used === 0) {
7730 - //error_log("MXCHAT VECTORSTORE: No matches found - returning empty reference message");
7731 - // Empty return → assembler's NO RELEVANT CONTENT branch (plan d7daf8).
7732 - $content = '';
7733 - } else {
7734 - // Build response guidelines based on citation links setting
7735 - $content .= "\n## Response Guidelines ##\n" .
7736 - "You are an AI Chatbot. Answer naturally and helpfully using only the information from the references above. " .
7737 - "Be conversational and friendly, but never mention your knowledge base or training data. " .
7738 - "If you don't have specific information or are uncertain about any details, it's always " .
7739 - "better to honestly say you don't know rather than making up or guessing at answers. " .
7740 - "When information is incomplete, let them know you are unsure.\n\n";
7741 -
7742 - // Only add hyperlink instructions if citation links are enabled
7743 - if ($citation_links_enabled) {
7744 - $content .= "CRITICAL: When creating hyperlinks, always use proper markdown format with descriptive text: " .
7745 - "[descriptive text](url). NEVER use empty brackets like [](url). The text in brackets must describe what the link is about.";
7746 - } else {
7747 - $content .= "IMPORTANT: Do not include any citation links, source URLs, or hyperlinks in your responses. " .
7748 - "Simply provide helpful answers based on the reference information without citing sources.";
7749 - }
7750 - }
7751 -
7752 - //error_log("MXCHAT DEBUG: Vector Store search complete. Matches used: " . $matches_used);
7753 -
7754 - return trim($content);
7755 -}
7756 -
7757 -/**
7758 - * Check if the given model is an OpenAI chat model
7759 - *
7760 - * @param string $model The model ID
7761 - * @return bool True if it's an OpenAI model
7762 - */
7763 -private function is_openai_chat_model($model) {
7764 - $openai_prefixes = array('gpt-', 'o1-', 'o3-');
7765 - foreach ($openai_prefixes as $prefix) {
7766 - if (strpos($model, $prefix) === 0) {
7767 - return true;
7768 - }
7769 - }
7770 - return false;
7771 -}
7772 -
7773 -/**
7774 - * Get bot-specific Vector Store configuration
7775 - *
7776 - * @param string $bot_id The bot ID
7777 - * @return array Configuration array
7778 - */
7779 -private function get_bot_vectorstore_config($bot_id = 'default') {
7780 - // Admin Testing tab bot → resolve the DEFAULT bot's backend (see
7781 - // get_bot_pinecone_config). This getter already passes the real default
7782 - // config into the filter, so it was not broken — normalized anyway so the
7783 - // Testing bot can never drift from the front-end default.
7784 - if ($bot_id === 'testing') {
7785 - $bot_id = 'default';
7786 - }
7787 -
7788 - $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
7789 -
7790 - // Default global settings
7791 - $default_config = array(
7792 - 'use_vectorstore' => ($vectorstore_options['mxchat_use_openai_vectorstore'] ?? '0') === '1',
7793 - 'vectorstore_ids' => $vectorstore_options['mxchat_vectorstore_ids'] ?? '',
7794 - 'max_results' => $vectorstore_options['mxchat_vectorstore_max_results'] ?? 5
7795 - );
7796 -
7797 - // Allow multi-bot plugin to override with bot-specific settings
7798 - $bot_config = apply_filters('mxchat_get_bot_vectorstore_config', $default_config, $bot_id);
7799 -
7800 - // Preserve max_results from global settings if not set in bot config
7801 - if (!isset($bot_config['max_results'])) {
7802 - $bot_config['max_results'] = $default_config['max_results'];
7803 - }
7804 -
7805 - return $bot_config;
7806 -}
7807 -
7808 3850 private function mxchat_find_relevant_products($user_embedding) {
7809 3851 //error_log('MXChat Vector Search: Starting product search...');
7810 3852
7811 3853 // Retrieve the add-on settings from the database
@@ -7826,75 +3868,73 @@
7826 3868 }
7827 3869 private function find_relevant_products_wordpress($user_embedding) {
7828 3870 global $wpdb;
7829 3871 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
3872 + $cache_key = 'mxchat_system_prompt_embeddings';
3873 + $batch_size = 500;
7830 3874
7831 - if (!is_array($user_embedding)) {
7832 - return '';
7833 - }
3875 + // Original WordPress database search logic
3876 + // [Previous implementation remains the same]
3877 + $embeddings = wp_cache_get($cache_key, 'mxchat_system_prompts');
3878 + if ($embeddings === false) {
3879 + $embeddings = [];
3880 + $offset = 0;
7834 3881
7835 - // Streaming top-K pass: scan rows in small batches, keep only the top 3
7836 - // results above the similarity threshold. Peak memory is bounded by
7837 - // $batch_size embedding rows plus a 3-element top list.
7838 - $batch_size = 250;
7839 - $similarity_threshold = 0.85;
7840 - $top_k = 3;
7841 - $top_results = [];
7842 - $offset = 0;
3882 + do {
3883 + $query = $wpdb->prepare(
3884 + "SELECT id, embedding_vector
3885 + FROM {$system_prompt_table}
3886 + LIMIT %d OFFSET %d",
3887 + $batch_size,
3888 + $offset
3889 + );
7843 3890
7844 - do {
7845 - $batch = $wpdb->get_results($wpdb->prepare(
7846 - "SELECT id, embedding_vector
7847 - FROM {$system_prompt_table}
7848 - LIMIT %d OFFSET %d",
7849 - $batch_size,
7850 - $offset
7851 - ));
3891 + $batch = $wpdb->get_results($query);
3892 + if (empty($batch)) {
3893 + break;
3894 + }
7852 3895
7853 - if (empty($batch)) {
7854 - break;
7855 - }
3896 + $embeddings = array_merge($embeddings, $batch);
3897 + $offset += $batch_size;
7856 3898
7857 - foreach ($batch as $row) {
7858 - $database_embedding = $row->embedding_vector
7859 - ? unserialize($row->embedding_vector, ['allowed_classes' => false])
7860 - : null;
3899 + unset($batch);
7861 3900
7862 - if (!is_array($database_embedding)) {
7863 - unset($database_embedding);
7864 - continue;
7865 - }
3901 + } while (true);
7866 3902
3903 + if (empty($embeddings)) {
3904 + return '';
3905 + }
3906 + wp_cache_set($cache_key, $embeddings, 'mxchat_system_prompts', 3600);
3907 + }
3908 +
3909 + $relevant_results = [];
3910 + foreach ($embeddings as $embedding) {
3911 + $database_embedding = $embedding->embedding_vector
3912 + ? unserialize($embedding->embedding_vector, ['allowed_classes' => false])
3913 + : null;
3914 + if (is_array($database_embedding) && is_array($user_embedding)) {
7867 3915 $similarity = $this->mxchat_calculate_cosine_similarity($user_embedding, $database_embedding);
7868 - unset($database_embedding);
7869 -
7870 - if ($similarity < $similarity_threshold) {
7871 - continue;
7872 - }
7873 -
7874 - // Insert into bounded top-K (kept sorted descending)
7875 - if (count($top_results) < $top_k) {
7876 - $top_results[] = ['id' => $row->id, 'similarity' => $similarity];
7877 - usort($top_results, function ($a, $b) {
7878 - return $b['similarity'] <=> $a['similarity'];
7879 - });
7880 - } elseif ($similarity > $top_results[$top_k - 1]['similarity']) {
7881 - $top_results[$top_k - 1] = ['id' => $row->id, 'similarity' => $similarity];
7882 - usort($top_results, function ($a, $b) {
7883 - return $b['similarity'] <=> $a['similarity'];
7884 - });
7885 - }
3916 + $relevant_results[] = [
3917 + 'id' => $embedding->id,
3918 + 'similarity' => $similarity
3919 + ];
7886 3920 }
3921 + unset($database_embedding);
3922 + }
7887 3923
7888 - unset($batch);
7889 - $offset += $batch_size;
7890 - } while (true);
3924 + // Use fixed threshold for products
3925 + $similarity_threshold = 0.85;
7891 3926
7892 - if (empty($top_results)) {
7893 - return '';
7894 - }
3927 + $relevant_results = array_filter($relevant_results, function ($result) use ($similarity_threshold) {
3928 + return $result['similarity'] >= $similarity_threshold;
3929 + });
3930 + usort($relevant_results, function ($a, $b) {
3931 + return $b['similarity'] <=> $a['similarity'];
3932 + });
7895 3933
3934 + $top_results = array_slice($relevant_results, 0, 5);
7896 3935 $content = '';
3936 +
7897 3937 foreach ($top_results as $result) {
7898 3938 $chunk_content = $this->fetch_content_with_product_links($result['id']);
7899 3939 $content .= $chunk_content . "\n\n";
7900 3940 }
@@ -7900,10 +3940,8 @@
7900 3940 }
7901 3941
7902 3942 return trim($content);
7903 3943 }
7904 -
7905 -
7906 3944 private function find_relevant_products_pinecone($user_embedding) {
7907 3945 //error_log('Starting Pinecone product search...');
7908 3946
7909 3947 $options = get_option('mxchat_pinecone_addon_options', array());
@@ -7978,10 +4016,8 @@
7978 4016 }
7979 4017
7980 4018 return trim($content);
7981 4019 }
7982 -
7983 -
7984 4020 private function fetch_content_with_product_links($most_relevant_id) {
7985 4021 global $wpdb;
7986 4022 $system_prompt_table = $wpdb->prefix . 'mxchat_system_prompt_content';
7987 4023
@@ -8001,601 +4037,13 @@
8001 4037 return null;
8002 4038 }
8003 4039
8004 4040 /**
8005 - * Get system instructions for a specific bot or default
8006 - * Checks for multi-bot add-on and uses bot-specific instructions if available
8007 - * Automatically strips URLs if citation links are disabled
8008 - * Replaces {visitor_name} placeholder with actual visitor name if available
8009 - *
8010 - * @param string $bot_id The bot ID to get instructions for
8011 - * @param string $session_id Optional session ID to lookup visitor name
4041 + * Modified streaming functions to include testing data
8012 4042 */
8013 -private function get_system_instructions($bot_id = 'default', $session_id = '') {
8014 - $instructions = '';
8015 4043
8016 - // Check if multi-bot add-on is active
8017 - if (class_exists('MxChat_Multi_Bot_Core_Manager') && $bot_id !== 'default') {
8018 - // Get bot-specific options from multi-bot add-on
8019 - $bot_options = apply_filters('mxchat_get_bot_options', array(), $bot_id);
8020 -
8021 - // If bot has custom system instructions, use those
8022 - if (!empty($bot_options['system_prompt_instructions'])) {
8023 - $instructions = $bot_options['system_prompt_instructions'];
8024 - }
8025 - }
8026 -
8027 - // Fall back to default system instructions
8028 - if (empty($instructions)) {
8029 - $instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
8030 - }
8031 -
8032 - // Check if citation links are disabled - if so, strip URLs from instructions
8033 - $fresh_options = get_option('mxchat_options', []);
8034 - $citation_links_enabled = isset($fresh_options['citation_links_toggle']) ? ($fresh_options['citation_links_toggle'] === 'on') : true;
8035 -
8036 - if (!$citation_links_enabled && !empty($instructions)) {
8037 - $instructions = preg_replace('#\bhttps?://[^\s<>"\']+#i', '', $instructions);
8038 - $instructions = preg_replace('/\s+/', ' ', trim($instructions)); // Clean up extra spaces
8039 - }
8040 -
8041 - // Replace {visitor_name} placeholder with actual visitor name if available
8042 - if (!empty($instructions) && !empty($session_id) && stripos($instructions, '{visitor_name}') !== false) {
8043 - $name_option_key = "mxchat_name_{$session_id}";
8044 - $visitor_name = get_option($name_option_key, '');
8045 -
8046 - if (!empty($visitor_name)) {
8047 - $instructions = str_ireplace('{visitor_name}', sanitize_text_field($visitor_name), $instructions);
8048 - } else {
8049 - // Remove placeholder if no name is available
8050 - $instructions = str_ireplace('{visitor_name}', '', $instructions);
8051 - $instructions = preg_replace('/\s{2,}/', ' ', trim($instructions)); // Clean up extra spaces
8052 - }
8053 - }
8054 -
8055 - // {context} placeholder (plan 59bc1b): inject the assembled knowledge-base
8056 - // block where the owner placed the token. Runs after the URL-strip and
8057 - // {visitor_name} handling and before the developer filter, so filtered
8058 - // instructions already show the final prompt. Only active once the KB
8059 - // assembly has stashed the block (context_kb_block non-null) — the early
8060 - // URL-extraction call happens before assembly and leaves the token alone.
8061 - if ($this->context_kb_block !== null && !empty($instructions) && stripos($instructions, '{context}') !== false) {
8062 - $pos = stripos($instructions, '{context}');
8063 - $instructions = substr($instructions, 0, $pos)
8064 - . rtrim($this->context_kb_block) . "\n"
8065 - . substr($instructions, $pos + strlen('{context}'));
8066 - // Additional occurrences are stripped — never duplicate the KB block.
8067 - $instructions = str_ireplace('{context}', '', $instructions);
8068 - }
8069 -
8070 - // Allow developers to filter system instructions and process shortcodes
8071 - $instructions = apply_filters('mxchat_system_instructions', $instructions, $bot_id, $session_id);
8072 - $instructions = do_shortcode($instructions);
8073 -
8074 - return $instructions;
8075 -}
8076 -/**
8077 - * Get the current bot ID from session or request context
8078 - */
8079 -private function get_current_bot_id($session_id = '') {
8080 - // First, check if bot_id is passed in the current request
8081 - if (isset($_POST['bot_id']) && !empty($_POST['bot_id'])) {
8082 - return sanitize_key($_POST['bot_id']);
8083 - }
8084 -
8085 - // If not in POST, try to get it from session data
8086 - if (!empty($session_id)) {
8087 - $bot_id = get_option("mxchat_session_bot_{$session_id}", '');
8088 - if (!empty($bot_id)) {
8089 - return $bot_id;
8090 - }
8091 - }
8092 -
8093 - // Fall back to default
8094 - return 'default';
8095 -}
8096 -/* ====================================================================== *
8097 - * Native function-calling loop (plan-mxchat-20260617-a41dee)
8098 - *
8099 - * Model-driven tool use. The model is offered MxChat's enabled callbacks as
8100 - * tools (sourced from MxChat_Tool_Registry, the single source the admin AI
8101 - * Tools checklist also reads). When the model calls a tool, the matching
8102 - * callback runs through its EXISTING permission checks, its output is fed
8103 - * back, and the loop continues up to a depth cap. INDEPENDENT of the
8104 - * intent→callback router — it runs only after intents miss, and works with
8105 - * ZERO Actions created.
8106 - *
8107 - * Entered ONLY when: function calling is enabled + the active model is
8108 - * tool-capable + at least one tool is enabled. Default-off, so existing
8109 - * installs never enter this branch (byte-for-byte unchanged behavior). The
8110 - * tool round is buffered (non-streaming) per the plan; the final answer is
8111 - * emitted via the same SSE/JSON envelopes the normal path uses.
8112 - * ====================================================================== */
8113 -
8114 -/** Gate: should the function-calling loop handle this turn? */
8115 -private function mxchat_fc_should_run($selected_model) {
8116 - if (!class_exists('MxChat_Tool_Registry') || !MxChat_Tool_Registry::is_enabled()) {
8117 - return false;
8118 - }
8119 - if (class_exists('MxChat_Model_Catalog') && !MxChat_Model_Catalog::supports_tools($selected_model)) {
8120 - return false;
8121 - }
8122 - $tools = MxChat_Tool_Registry::enabled_tools();
8123 - return !empty($tools);
8124 -}
8125 -
8126 -private function mxchat_fc_log($msg) {
8127 - if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
8128 - error_log('[MxChat FC] ' . $msg);
8129 - }
8130 -}
8131 -
8132 -/**
8133 - * Resolve provider transport details. Returns null when FC can't run for this
8134 - * model/config (missing key, unsupported provider) so the caller falls back to
8135 - * the normal path. OpenAI/xAI/DeepSeek/OpenRouter/Custom share the
8136 - * OpenAI-compatible 'openai' family; Claude and Gemini are distinct.
8137 - */
8138 -private function mxchat_fc_resolve_provider($selected_model, $opts) {
8139 - // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
8140 - // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
8141 - if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
8142 - elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
8143 - if ($selected_model === 'openrouter') {
8144 - $model = isset($opts['openrouter_selected_model']) ? $opts['openrouter_selected_model'] : '';
8145 - $key = isset($opts['openrouter_api_key']) ? $opts['openrouter_api_key'] : '';
8146 - if ($model === '' || $key === '') return null;
8147 - return array('family'=>'openai','model'=>$model,'url'=>'https://openrouter.ai/api/v1/chat/completions',
8148 - 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8149 - }
8150 - $prefix = strtolower(explode('-', $selected_model)[0]);
8151 - switch ($prefix) {
8152 - case 'gpt': case 'o1': case 'o3': case 'o4':
8153 - $key = isset($opts['api_key']) ? $opts['api_key'] : '';
8154 - if ($key === '') return null;
8155 - return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.openai.com/v1/chat/completions',
8156 - 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8157 - case 'claude':
8158 - $key = isset($opts['claude_api_key']) ? $opts['claude_api_key'] : '';
8159 - if ($key === '') return null;
8160 - return array('family'=>'anthropic','model'=>$selected_model,'url'=>'https://api.anthropic.com/v1/messages',
8161 - 'headers'=>array('Content-Type'=>'application/json','x-api-key'=>$key,'anthropic-version'=>'2023-06-01'),'tag'=>'anthropic');
8162 - case 'gemini':
8163 - $key = isset($opts['gemini_api_key']) ? $opts['gemini_api_key'] : '';
8164 - if ($key === '') return null;
8165 - return array('family'=>'gemini','model'=>$selected_model,'key'=>$key,'tag'=>'gemini');
8166 - case 'grok': case 'xai':
8167 - $key = isset($opts['xai_api_key']) ? $opts['xai_api_key'] : '';
8168 - if ($key === '') return null;
8169 - return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.x.ai/v1/chat/completions',
8170 - 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'xai');
8171 - case 'deepseek':
8172 - $key = isset($opts['deepseek_api_key']) ? $opts['deepseek_api_key'] : '';
8173 - if ($key === '') return null;
8174 - return array('family'=>'openai','model'=>$selected_model,'url'=>'https://api.deepseek.com/v1/chat/completions',
8175 - 'headers'=>array('Content-Type'=>'application/json','Authorization'=>'Bearer '.$key),'tag'=>'openai');
8176 - case 'custom':
8177 - $base = isset($opts['custom_provider_base_url']) ? rtrim($opts['custom_provider_base_url'], '/') : '';
8178 - $key = isset($opts['custom_provider_api_key']) ? $opts['custom_provider_api_key'] : '';
8179 - $model = isset($opts['custom_provider_model']) ? $opts['custom_provider_model'] : '';
8180 - if ($base === '' || $model === '') return null;
8181 - $url = (strpos($base, 'chat/completions') !== false) ? $base : $base . '/chat/completions';
8182 - $headers = array('Content-Type'=>'application/json');
8183 - if ($key !== '') $headers['Authorization'] = 'Bearer '.$key;
8184 - return array('family'=>'openai','model'=>$model,'url'=>$url,'headers'=>$headers,'tag'=>'openai');
8185 - }
8186 - return null;
8187 -}
8188 -
8189 -/**
8190 - * Top-level function-calling attempt. Returns:
8191 - * ['handled'=>true, 'text'=>'<final answer>'] when the model used ≥1 tool
8192 - * ['handled'=>false] otherwise (caller falls back
8193 - * to the normal streamed path)
8194 - */
8195 -private function mxchat_fc_attempt($message, $relevant_content, $conversation_history, $selected_model, $opts, $session_id, $user_id) {
8196 - $prov = $this->mxchat_fc_resolve_provider($selected_model, $opts);
8197 - if (!$prov) {
8198 - return array('handled' => false);
8199 - }
8200 - $tools = MxChat_Tool_Registry::enabled_tools();
8201 - if (empty($tools)) {
8202 - return array('handled' => false);
8203 - }
8204 -
8205 - $bot_id = $this->get_current_bot_id($session_id);
8206 - $system = $this->get_system_instructions($bot_id, $session_id);
8207 -
8208 - // Force callbacks into return-mode (some echo SSE directly when streaming);
8209 - // we buffer the whole tool round, then emit once. Restored in finally.
8210 - $prev_streaming = $this->is_streaming;
8211 - $this->is_streaming = false;
4044 +private function mxchat_generate_response($relevant_content, $api_key, $xai_api_key, $claude_api_key, $deepseek_api_key, $gemini_api_key, $conversation_history, $streaming = false, $session_id = '', $testing_data = null) {
8212 4045 try {
8213 - if ($prov['family'] === 'anthropic') {
8214 - return $this->mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8215 - } elseif ($prov['family'] === 'gemini') {
8216 - return $this->mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8217 - }
8218 - return $this->mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $message, $user_id, $session_id);
8219 - } catch (\Throwable $e) {
8220 - $this->mxchat_fc_log('attempt threw: ' . $e->getMessage());
8221 - return array('handled' => false);
8222 - } finally {
8223 - $this->is_streaming = $prev_streaming;
8224 - }
8225 -}
8226 -
8227 -/** Normalize MxChat history rows to [{role:user|assistant, content}]. */
8228 -private function mxchat_fc_normalize_history($conversation_history) {
8229 - $out = array();
8230 - if (!is_array($conversation_history)) return $out;
8231 - foreach ($conversation_history as $m) {
8232 - if (!is_array($m) || !isset($m['role']) || !isset($m['content'])) continue;
8233 - $role = $m['role'];
8234 - if ($role === 'bot' || $role === 'agent') $role = 'assistant';
8235 - if (!in_array($role, array('user', 'assistant'), true)) $role = 'user';
8236 - $out[] = array('role' => $role, 'content' => (string) $m['content']);
8237 - }
8238 - return $out;
8239 -}
8240 -
8241 -/** Execute the matched callback for a tool call. Returns ['ok'=>bool,'content'=>string]. */
8242 -private function mxchat_fc_execute_tool($tool_name, $args, $orig_message, $user_id, $session_id) {
8243 - $tool = MxChat_Tool_Registry::tool_by_name($tool_name, true); // enabled-only
8244 - if (!$tool) {
8245 - return array('ok' => false, 'content' => 'This tool is not available or not enabled.');
8246 - }
8247 - $fn = $tool['callback'];
8248 -
8249 - // MxChat callbacks are message-driven: hand them the model's `query`
8250 - // (falling back to the original user message).
8251 - $query = '';
8252 - if (is_array($args) && isset($args['query']) && is_string($args['query'])) {
8253 - $query = $args['query'];
8254 - }
8255 - if ($query === '') $query = $orig_message;
8256 -
8257 - // Synthetic intent row (matches wp_mxchat_intents columns → no undefined-prop warnings).
8258 - $synthetic_intent = (object) array(
8259 - 'id' => 0, 'intent_label' => $tool['label'], 'phrases' => '',
8260 - 'embedding_vector' => '', 'callback_function' => $fn,
8261 - 'similarity_threshold' => 0.0, 'enabled' => 1, 'enabled_bots' => null,
8262 - );
8263 -
8264 - try {
8265 - if (!empty($tool['is_addon'])) {
8266 - $result = apply_filters($fn, false, $query, $user_id, $session_id, $synthetic_intent);
8267 - } elseif (method_exists($this, $fn)) {
8268 - $result = call_user_func(array($this, $fn), $query, $user_id, $session_id, $synthetic_intent, null);
8269 - } else {
8270 - return array('ok' => false, 'content' => 'Tool implementation not found.');
8271 - }
8272 - } catch (\Throwable $e) {
8273 - $this->mxchat_fc_log("tool {$fn} threw: " . $e->getMessage());
8274 - return array('ok' => false, 'content' => 'The tool failed to run.');
8275 - }
8276 -
8277 - // plan-mxchat-20260617-48a57a — surface UI-bearing tool output.
8278 - // If the callback produced a UI element (generated image, product card, image
8279 - // gallery), its html MUST reach the FRONTEND as a real rendered bot message —
8280 - // NOT be stripped to text and handed to the model to paraphrase (that was the
8281 - // bug: under function calling, UI-bearing actions rendered nothing). Capture
8282 - // the html here; the FC outcome handler emits it in the response envelope.
8283 - $ui = $this->mxchat_fc_ui_payload_from($result);
8284 - if ($ui['html'] !== '' || !empty($ui['images'])) {
8285 - if ($ui['html'] !== '') {
8286 - $this->fc_ui_html .= ($this->fc_ui_html !== '' ? "\n" : '') . $ui['html'];
8287 - }
8288 - if (!empty($ui['images']) && is_array($ui['images'])) {
8289 - $this->fc_ui_images = array_merge($this->fc_ui_images, $ui['images']);
8290 - }
8291 - $this->fc_ui_captured = true;
8292 -
8293 - // Persist the html to the transcript ONLY if the callback did not already
8294 - // do so itself. Core image/search callbacks self-save (text + html);
8295 - // add-on callbacks (e.g. woo product cards) return html for the caller to
8296 - // save. ui_self_saves carries this from the registry; default by source
8297 - // (core self-saves, add-on does not) when a tool predates the flag.
8298 - $self_saves = array_key_exists('ui_self_saves', $tool)
8299 - ? !empty($tool['ui_self_saves'])
8300 - : empty($tool['is_addon']);
8301 - if ($ui['html'] !== '' && !$self_saves) {
8302 - $this->mxchat_save_chat_message($session_id, 'bot', $ui['html']);
8303 - }
8304 -
8305 - // Hand the MODEL a short acknowledgment (never the raw or stripped html)
8306 - // so the loop can add a one-line caption without trying to re-describe a
8307 - // visual it cannot see and without duplicating the displayed element.
8308 - $summary = isset($ui['text']) ? trim((string) $ui['text']) : '';
8309 - $ack = __('[A visual result has already been shown to the user in the chat. Do not repeat or describe it in detail — reply with at most a brief one-line caption.]', 'mxchat');
8310 - $content = $summary !== '' ? ($ack . ' ' . $summary) : $ack;
8311 - $this->mxchat_fc_log("executed {$fn} → [ui payload surfaced] " . substr($content, 0, 120));
8312 - return array('ok' => true, 'content' => $content);
8313 - }
8314 -
8315 - $content = $this->mxchat_fc_stringify_result($result);
8316 - $this->mxchat_fc_log("executed {$fn} → " . substr($content, 0, 160));
8317 - return array('ok' => true, 'content' => $content);
8318 -}
8319 -
8320 -/**
8321 - * Extract a UI payload (html + images + text) from a tool callback's return,
8322 - * falling back to $this->fallbackResponse for callbacks that return true after
8323 - * setting it. plan-mxchat-20260617-48a57a.
8324 - *
8325 - * @return array{html:string,images:array,text:string}
8326 - */
8327 -private function mxchat_fc_ui_payload_from($result) {
8328 - $src = null;
8329 - if (is_array($result)) {
8330 - $src = $result;
8331 - } elseif ($result === true && isset($this->fallbackResponse) && is_array($this->fallbackResponse)) {
8332 - $src = $this->fallbackResponse;
8333 - }
8334 - $html = (is_array($src) && isset($src['html']) && is_string($src['html'])) ? $src['html'] : '';
8335 - $images = (is_array($src) && isset($src['images']) && is_array($src['images'])) ? $src['images'] : array();
8336 - $text = (is_array($src) && isset($src['text'])) ? (string) $src['text'] : '';
8337 - return array('html' => $html, 'images' => $images, 'text' => $text);
8338 -}
8339 -
8340 -/** Coerce a callback's return (string|array|true|false) into a tool-result string. */
8341 -private function mxchat_fc_stringify_result($result) {
8342 - if (is_string($result)) {
8343 - return $result === '' ? 'No result.' : $result;
8344 - }
8345 - if ($result === true) {
8346 - // Callbacks that set fallbackResponse and return true.
8347 - $fb = isset($this->fallbackResponse) ? $this->fallbackResponse : null;
8348 - if (is_array($fb)) {
8349 - if (!empty($fb['text'])) return (string) $fb['text'];
8350 - if (!empty($fb['html'])) return wp_strip_all_tags((string) $fb['html']);
8351 - }
8352 - return 'Done.';
8353 - }
8354 - if ($result === false || $result === null) {
8355 - return 'No result.';
8356 - }
8357 - if (is_array($result)) {
8358 - if (isset($result['text']) && $result['text'] !== '') return (string) $result['text'];
8359 - if (isset($result['html']) && $result['html'] !== '') return wp_strip_all_tags((string) $result['html']);
8360 - $json = wp_json_encode($result);
8361 - return $json !== false ? $json : 'No result.';
8362 - }
8363 - return (string) $result;
8364 -}
8365 -
8366 -/** HTTP code + decoded body for a function-calling request. */
8367 -private function mxchat_fc_post($url, $body, $headers, $tag) {
8368 - $args = array(
8369 - 'body' => wp_json_encode($body),
8370 - 'headers' => $headers,
8371 - 'timeout' => 60,
8372 - 'redirection' => 5,
8373 - 'blocking' => true,
8374 - 'httpversion' => '1.0',
8375 - 'sslverify' => true,
8376 - );
8377 - $response = $this->mxchat_provider_call_with_retry($url, $args, $tag);
8378 - if (is_wp_error($response)) {
8379 - return array('code' => 0, 'data' => null, 'error' => $response->get_error_message());
8380 - }
8381 - $code = (int) wp_remote_retrieve_response_code($response);
8382 - $data = json_decode(wp_remote_retrieve_body($response), true);
8383 - return array('code' => $code, 'data' => $data, 'error' => null);
8384 -}
8385 -
8386 -/* ---------------- OpenAI-compatible loop (OpenAI/xAI/DeepSeek/OpenRouter/Custom) -------------- */
8387 -private function mxchat_fc_loop_openai($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8388 - $messages = array();
8389 - $messages[] = array('role' => 'system', 'content' => $system . ' ' . $relevant_content);
8390 - foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8391 - $messages[] = $m;
8392 - }
8393 -
8394 - $depth = MxChat_Tool_Registry::max_depth();
8395 - $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8396 - $tool_schema = MxChat_Tool_Registry::to_openai_tools($tools);
8397 - $used_tool = false;
8398 - $calls_made = 0;
8399 -
8400 - for ($step = 0; $step <= $depth; $step++) {
8401 - $offer_tools = ($step < $depth) && !empty($tool_schema);
8402 - $body = array('model' => $prov['model'], 'messages' => $messages, 'temperature' => 1, 'stream' => false);
8403 - if (strpos($prov['url'], 'api.deepseek.com') !== false) {
8404 - // DeepSeek V4 defaults to thinking mode ON; tool loops want fast
8405 - // deterministic non-thinking turns (legacy deepseek-chat semantics).
8406 - $body['thinking'] = array('type' => 'disabled');
8407 - }
8408 - if ($offer_tools) {
8409 - $body['tools'] = $tool_schema;
8410 - $body['tool_choice'] = 'auto';
8411 - }
8412 - $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8413 - if ($r['code'] !== 200 || !is_array($r['data'])) {
8414 - $this->mxchat_fc_log('openai call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8415 - return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8416 - }
8417 - $msg = isset($r['data']['choices'][0]['message']) ? $r['data']['choices'][0]['message'] : null;
8418 - if (!$msg) {
8419 - return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8420 - }
8421 - $tool_calls = isset($msg['tool_calls']) && is_array($msg['tool_calls']) ? $msg['tool_calls'] : array();
8422 - if (empty($tool_calls)) {
8423 - $text = isset($msg['content']) ? trim((string) $msg['content']) : '';
8424 - if (!$used_tool) return array('handled' => false); // model never used a tool → normal path
8425 - return array('handled' => true, 'text' => ($text !== '' ? $text : $this->mxchat_fc_giveup_text()));
8426 - }
8427 - // Append the assistant tool-call turn verbatim, then a tool result per call.
8428 - $used_tool = true;
8429 - $messages[] = $msg;
8430 - foreach ($tool_calls as $tc) {
8431 - if ($calls_made >= $budget) break;
8432 - $calls_made++;
8433 - $name = isset($tc['function']['name']) ? $tc['function']['name'] : '';
8434 - $args = array();
8435 - if (isset($tc['function']['arguments'])) {
8436 - $decoded = json_decode($tc['function']['arguments'], true);
8437 - if (is_array($decoded)) $args = $decoded;
8438 - }
8439 - $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8440 - $messages[] = array(
8441 - 'role' => 'tool',
8442 - 'tool_call_id' => isset($tc['id']) ? $tc['id'] : '',
8443 - 'content' => $exec['content'],
8444 - );
8445 - }
8446 - }
8447 - return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8448 -}
8449 -
8450 -/* ---------------- Anthropic Claude loop ---------------- */
8451 -private function mxchat_fc_loop_anthropic($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8452 - $messages = $this->mxchat_fc_normalize_history($conversation_history);
8453 - $messages[] = array('role' => 'user', 'content' => $relevant_content);
8454 -
8455 - $depth = MxChat_Tool_Registry::max_depth();
8456 - $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8457 - $tool_schema = MxChat_Tool_Registry::to_anthropic_tools($tools);
8458 - $omit_temp = $this->mxchat_claude_omits_temperature($prov['model']);
8459 - $used_tool = false;
8460 - $calls_made = 0;
8461 -
8462 - for ($step = 0; $step <= $depth; $step++) {
8463 - $offer_tools = ($step < $depth) && !empty($tool_schema);
8464 - $body = array('model' => $prov['model'], 'max_tokens' => 1024, 'temperature' => 0.8,
8465 - 'messages' => $messages, 'system' => $system);
8466 - if ($omit_temp) unset($body['temperature']);
8467 - if ($offer_tools) {
8468 - $body['tools'] = $tool_schema;
8469 - $body['tool_choice'] = array('type' => 'auto');
8470 - }
8471 - $r = $this->mxchat_fc_post($prov['url'], $body, $prov['headers'], $prov['tag']);
8472 - if ($r['code'] !== 200 || !is_array($r['data'])) {
8473 - $this->mxchat_fc_log('anthropic call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8474 - return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8475 - }
8476 - $content = isset($r['data']['content']) && is_array($r['data']['content']) ? $r['data']['content'] : array();
8477 - $tool_uses = array();
8478 - $text_out = '';
8479 - foreach ($content as $block) {
8480 - if (!isset($block['type'])) continue;
8481 - if ($block['type'] === 'tool_use') {
8482 - $tool_uses[] = $block;
8483 - } elseif ($block['type'] === 'text' && isset($block['text'])) {
8484 - $text_out .= $block['text'];
8485 - }
8486 - }
8487 - if (empty($tool_uses)) {
8488 - if (!$used_tool) return array('handled' => false);
8489 - $text_out = trim($text_out);
8490 - return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8491 - }
8492 - // Append the assistant turn (the full content array), then a user turn of tool_result blocks.
8493 - $used_tool = true;
8494 - $messages[] = array('role' => 'assistant', 'content' => $content);
8495 - $results = array();
8496 - foreach ($tool_uses as $tu) {
8497 - if ($calls_made >= $budget) break;
8498 - $calls_made++;
8499 - $name = isset($tu['name']) ? $tu['name'] : '';
8500 - $args = isset($tu['input']) && is_array($tu['input']) ? $tu['input'] : array();
8501 - $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8502 - $results[] = array(
8503 - 'type' => 'tool_result',
8504 - 'tool_use_id' => isset($tu['id']) ? $tu['id'] : '',
8505 - 'content' => $exec['content'],
8506 - );
8507 - }
8508 - $messages[] = array('role' => 'user', 'content' => $results);
8509 - }
8510 - return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8511 -}
8512 -
8513 -/* ---------------- Google Gemini loop ---------------- */
8514 -private function mxchat_fc_loop_gemini($prov, $system, $relevant_content, $conversation_history, $tools, $orig_message, $user_id, $session_id) {
8515 - $contents = array();
8516 - $contents[] = array('role' => 'user', 'parts' => array(array('text' => '[System Instructions] ' . $system . ' ' . $relevant_content)));
8517 - $contents[] = array('role' => 'model', 'parts' => array(array('text' => 'I understand and will follow these instructions.')));
8518 - foreach ($this->mxchat_fc_normalize_history($conversation_history) as $m) {
8519 - $contents[] = array('role' => ($m['role'] === 'assistant' ? 'model' : 'user'),
8520 - 'parts' => array(array('text' => $m['content'])));
8521 - }
8522 -
8523 - $depth = MxChat_Tool_Registry::max_depth();
8524 - $budget = MxChat_Tool_Registry::max_tool_calls_per_turn();
8525 - $tool_schema = MxChat_Tool_Registry::to_gemini_tools($tools);
8526 - // Function calling (tools + functionDeclarations + toolConfig) is a v1beta feature on the
8527 - // Generative Language REST API. The v1 endpoint silently ignores the tools array, so a
8528 - // non-preview model (e.g. gemini-2.5-pro, gemini-3.5-flash, gemini-3.1-flash-lite) would
8529 - // just answer in text and never emit a tool call. Always use v1beta for the FC loop —
8530 - // confirmed against Google's function-calling docs (their REST example targets
8531 - // v1beta/models/gemini-3.5-flash:generateContent). v1beta is a superset, so every model
8532 - // reachable on v1 is also reachable here.
8533 - $api_version = 'v1beta';
8534 - $url = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $prov['model'] . ':generateContent?key=' . $prov['key'];
8535 - $headers = array('Content-Type' => 'application/json');
8536 - $used_tool = false;
8537 - $calls_made = 0;
8538 -
8539 - for ($step = 0; $step <= $depth; $step++) {
8540 - $offer_tools = ($step < $depth) && !empty($tool_schema);
8541 - $body = array(
8542 - 'contents' => $contents,
8543 - 'generationConfig' => array('temperature' => 0.7, 'topP' => 0.95, 'topK' => 40, 'maxOutputTokens' => 8192),
8544 - );
8545 - if ($offer_tools) {
8546 - $body['tools'] = $tool_schema;
8547 - $body['toolConfig'] = array('functionCallingConfig' => array('mode' => 'AUTO'));
8548 - }
8549 - $r = $this->mxchat_fc_post($url, $body, $headers, 'gemini');
8550 - if ($r['code'] !== 200 || !is_array($r['data']) || isset($r['data']['error'])) {
8551 - $this->mxchat_fc_log('gemini call failed: code=' . $r['code'] . ' err=' . ($r['error'] ?? ''));
8552 - return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8553 - }
8554 - $parts = isset($r['data']['candidates'][0]['content']['parts']) && is_array($r['data']['candidates'][0]['content']['parts'])
8555 - ? $r['data']['candidates'][0]['content']['parts'] : array();
8556 - $fn_calls = array();
8557 - $text_out = '';
8558 - foreach ($parts as $p) {
8559 - if (isset($p['functionCall'])) {
8560 - $fn_calls[] = $p['functionCall'];
8561 - } elseif (isset($p['text'])) {
8562 - $text_out .= $p['text'];
8563 - }
8564 - }
8565 - if (empty($fn_calls)) {
8566 - if (!$used_tool) return array('handled' => false);
8567 - $text_out = trim($text_out);
8568 - return array('handled' => true, 'text' => ($text_out !== '' ? $text_out : $this->mxchat_fc_giveup_text()));
8569 - }
8570 - // Append the model turn (its parts) then a user turn of functionResponse parts.
8571 - $used_tool = true;
8572 - $contents[] = array('role' => 'model', 'parts' => $parts);
8573 - $resp_parts = array();
8574 - foreach ($fn_calls as $fcall) {
8575 - if ($calls_made >= $budget) break;
8576 - $calls_made++;
8577 - $name = isset($fcall['name']) ? $fcall['name'] : '';
8578 - $args = isset($fcall['args']) && is_array($fcall['args']) ? $fcall['args'] : array();
8579 - $exec = $this->mxchat_fc_execute_tool($name, $args, $orig_message, $user_id, $session_id);
8580 - $fr = array('name' => $name, 'response' => array('result' => $exec['content']));
8581 - // Gemini 3 function calls carry a unique id; echo the matching id back in the
8582 - // functionResponse so the model maps the result to the right call (Google REST
8583 - // guidance). Older models omit the id — then we send none, exactly as before.
8584 - if (isset($fcall['id']) && $fcall['id'] !== '') { $fr['id'] = $fcall['id']; }
8585 - $resp_parts[] = array('functionResponse' => $fr);
8586 - }
8587 - $contents[] = array('role' => 'user', 'parts' => $resp_parts);
8588 - }
8589 - return $used_tool ? array('handled' => true, 'text' => $this->mxchat_fc_giveup_text()) : array('handled' => false);
8590 -}
8591 -
8592 -private function mxchat_fc_giveup_text() {
8593 - return esc_html__('I looked into that but could not put together a final answer. Please try rephrasing your request.', 'mxchat');
8594 -}
8595 -
8596 -private function mxchat_generate_response($relevant_content, $api_key, $xai_api_key, $claude_api_key, $deepseek_api_key, $gemini_api_key, $openrouter_api_key, $conversation_history, $streaming = false, $session_id = '', $testing_data = null, $selected_model = 'gpt-5.6-sol') {
8597 - try {
8598 4046 if (!$relevant_content) {
8599 4047 $error_response = [
8600 4048 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
8601 4049 'error_code' => 'no_relevant_content'
@@ -8600,75 +4048,25 @@
8600 4048 'error' => esc_html__("I couldn't find relevant information on that topic.", 'mxchat'),
8601 4049 'error_code' => 'no_relevant_content'
8602 4050 ];
8603 4051
4052 + // Add testing data to error response if available
8604 4053 if ($testing_data !== null) {
8605 4054 $error_response['testing_data'] = $testing_data;
4055 + //error_log("MxChat Testing: Added testing data to no_relevant_content error");
8606 4056 }
8607 4057
8608 4058 return $error_response;
8609 4059 }
8610 4060
4061 + // Ensure conversation_history is an array
8611 4062 if (!is_array($conversation_history)) {
8612 4063 $conversation_history = array();
8613 4064 }
8614 4065
8615 - // Check if this is an OpenRouter model
8616 - if ($selected_model === 'openrouter') {
8617 - // Get the actual OpenRouter model from options
8618 - $openrouter_selected_model = $this->options['openrouter_selected_model'] ?? '';
8619 -
8620 - if (empty($openrouter_selected_model)) {
8621 - $error_response = [
8622 - 'error' => esc_html__('No OpenRouter model selected. Please select a model in settings.', 'mxchat'),
8623 - 'error_code' => 'no_openrouter_model_selected'
8624 - ];
8625 - if ($testing_data !== null) {
8626 - $error_response['testing_data'] = $testing_data;
8627 - }
8628 - return $error_response;
8629 - }
8630 -
8631 - if (empty($openrouter_api_key)) {
8632 - $error_response = [
8633 - 'error' => esc_html__('OpenRouter API key is not configured', 'mxchat'),
8634 - 'error_code' => 'missing_openrouter_api_key'
8635 - ];
8636 - if ($testing_data !== null) {
8637 - $error_response['testing_data'] = $testing_data;
8638 - }
8639 - return $error_response;
8640 - }
8641 -
8642 - if ($streaming) {
8643 - return $this->mxchat_generate_response_openrouter_stream(
8644 - $openrouter_selected_model,
8645 - $openrouter_api_key,
8646 - $conversation_history,
8647 - $relevant_content,
8648 - $session_id,
8649 - $testing_data
8650 - );
8651 - } else {
8652 - $response = $this->mxchat_generate_response_openrouter(
8653 - $openrouter_selected_model,
8654 - $openrouter_api_key,
8655 - $conversation_history,
8656 - $relevant_content,
8657 - $session_id
8658 - );
8659 - }
8660 -
8661 - if (is_array($response) && isset($response['error'])) {
8662 - if ($testing_data !== null) {
8663 - $response['testing_data'] = $testing_data;
8664 - }
8665 - return $response;
8666 - }
8667 -
8668 - return $response;
8669 - }
8670 -
4066 + // Get selected model with default fallback
4067 + $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-4o';
4068 +
8671 4069 // Extract model prefix to determine the provider
8672 4070 $model_parts = explode('-', $selected_model);
8673 4071 $provider = strtolower($model_parts[0]);
8674 4072
@@ -8688,10 +4086,9 @@
8688 4086 $response = $this->mxchat_generate_response_gemini(
8689 4087 $selected_model,
8690 4088 $gemini_api_key,
8691 4089 $conversation_history,
8692 - $relevant_content,
8693 - $session_id
4090 + $relevant_content
8694 4091 );
8695 4092 break;
8696 4093
8697 4094 case 'claude':
@@ -8711,9 +4108,9 @@
8711 4108 $claude_api_key,
8712 4109 $conversation_history,
8713 4110 $relevant_content,
8714 4111 $session_id,
8715 - $testing_data
4112 + $testing_data // Pass testing data
8716 4113 );
8717 4114 } else {
8718 4115 $response = $this->mxchat_generate_response_claude(
8719 4116 $selected_model,
@@ -8718,10 +4115,9 @@
8718 4115 $response = $this->mxchat_generate_response_claude(
8719 4116 $selected_model,
8720 4117 $claude_api_key,
8721 4118 $conversation_history,
8722 - $relevant_content,
8723 - $session_id
4119 + $relevant_content
8724 4120 );
8725 4121 }
8726 4122 break;
8727 4123
@@ -8742,9 +4138,9 @@
8742 4138 $xai_api_key,
8743 4139 $conversation_history,
8744 4140 $relevant_content,
8745 4141 $session_id,
8746 - $testing_data
4142 + $testing_data // Pass testing data
8747 4143 );
8748 4144 } else {
8749 4145 $response = $this->mxchat_generate_response_xai(
8750 4146 $selected_model,
@@ -8749,10 +4145,9 @@
8749 4145 $response = $this->mxchat_generate_response_xai(
8750 4146 $selected_model,
8751 4147 $xai_api_key,
8752 4148 $conversation_history,
8753 - $relevant_content,
8754 - $session_id
4149 + $relevant_content
8755 4150 );
8756 4151 }
8757 4152 break;
8758 4153
@@ -8773,9 +4168,9 @@
8773 4168 $deepseek_api_key,
8774 4169 $conversation_history,
8775 4170 $relevant_content,
8776 4171 $session_id,
8777 - $testing_data
4172 + $testing_data // Pass testing data
8778 4173 );
8779 4174 } else {
8780 4175 $response = $this->mxchat_generate_response_deepseek(
8781 4176 $selected_model,
@@ -8780,44 +4175,13 @@
8780 4175 $response = $this->mxchat_generate_response_deepseek(
8781 4176 $selected_model,
8782 4177 $deepseek_api_key,
8783 4178 $conversation_history,
8784 - $relevant_content,
8785 - $session_id
4179 + $relevant_content
8786 4180 );
8787 4181 }
8788 4182 break;
8789 4183
8790 - case 'custom':
8791 - // Custom (OpenAI-compatible) provider — Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI
8792 - $cp_base_url = isset($this->options['custom_provider_base_url']) ? trim((string) $this->options['custom_provider_base_url']) : '';
8793 - if (empty($cp_base_url)) {
8794 - $error_response = [
8795 - 'error' => esc_html__('Custom provider is not configured. Set Base URL in MxChat → API Keys → Custom Provider.', 'mxchat'),
8796 - 'error_code' => 'missing_custom_provider_base_url'
8797 - ];
8798 - if ($testing_data !== null) {
8799 - $error_response['testing_data'] = $testing_data;
8800 - }
8801 - return $error_response;
8802 - }
8803 - if ($streaming) {
8804 - return $this->mxchat_generate_response_custom_stream(
8805 - $selected_model,
8806 - $conversation_history,
8807 - $relevant_content,
8808 - $session_id,
8809 - $testing_data
8810 - );
8811 - } else {
8812 - $response = $this->mxchat_generate_response_custom(
8813 - $selected_model,
8814 - $conversation_history,
8815 - $relevant_content
8816 - );
8817 - }
8818 - break;
8819 -
8820 4184 case 'gpt':
8821 4185 case 'o1':
8822 4186 if (empty($api_key)) {
8823 4187 $error_response = [
@@ -8828,27 +4192,9 @@
8828 4192 $error_response['testing_data'] = $testing_data;
8829 4193 }
8830 4194 return $error_response;
8831 4195 }
8832 -
8833 - // Check if web search is enabled for this OpenAI model
8834 - $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8835 - // Models that don't support web search
8836 - $unsupported_web_search_models = array('gpt-4.1-nano');
8837 - $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8838 -
8839 - if ($web_search_enabled && $model_supports_web_search) {
8840 - // Use Responses API (required for some models, or when web search is enabled)
8841 - return $this->mxchat_generate_response_openai_web_search(
8842 - $selected_model,
8843 - $api_key,
8844 - $conversation_history,
8845 - $relevant_content,
8846 - $session_id,
8847 - $testing_data,
8848 - $streaming
8849 - );
8850 - } elseif ($streaming) {
4196 + if ($streaming) {
8851 4197 return $this->mxchat_generate_response_openai_stream(
8852 4198 $selected_model,
8853 4199 $api_key,
8854 4200 $conversation_history,
@@ -8853,9 +4199,9 @@
8853 4199 $api_key,
8854 4200 $conversation_history,
8855 4201 $relevant_content,
8856 4202 $session_id,
8857 - $testing_data
4203 + $testing_data // Pass testing data
8858 4204 );
8859 4205 } else {
8860 4206 $response = $this->mxchat_generate_response_openai(
8861 4207 $selected_model,
@@ -8860,15 +4206,15 @@
8860 4206 $response = $this->mxchat_generate_response_openai(
8861 4207 $selected_model,
8862 4208 $api_key,
8863 4209 $conversation_history,
8864 - $relevant_content,
8865 - $session_id
4210 + $relevant_content
8866 4211 );
8867 4212 }
8868 4213 break;
8869 4214
8870 4215 default:
4216 + // Default to OpenAI for custom models or unrecognized prefixes
8871 4217 if (empty($api_key)) {
8872 4218 $error_response = [
8873 4219 'error' => esc_html__('OpenAI API key is not configured', 'mxchat'),
8874 4220 'error_code' => 'missing_openai_api_key'
@@ -8877,25 +4223,9 @@
8877 4223 $error_response['testing_data'] = $testing_data;
8878 4224 }
8879 4225 return $error_response;
8880 4226 }
8881 -
8882 - // Check if web search is enabled (default case also handles OpenAI models)
8883 - $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
8884 - $unsupported_web_search_models = array('gpt-4.1-nano');
8885 - $model_supports_web_search = !in_array($selected_model, $unsupported_web_search_models);
8886 -
8887 - if ($web_search_enabled && $model_supports_web_search) {
8888 - return $this->mxchat_generate_response_openai_web_search(
8889 - $selected_model,
8890 - $api_key,
8891 - $conversation_history,
8892 - $relevant_content,
8893 - $session_id,
8894 - $testing_data,
8895 - $streaming
8896 - );
8897 - } elseif ($streaming) {
4227 + if ($streaming) {
8898 4228 return $this->mxchat_generate_response_openai_stream(
8899 4229 $selected_model,
8900 4230 $api_key,
8901 4231 $conversation_history,
@@ -8900,9 +4230,9 @@
8900 4230 $api_key,
8901 4231 $conversation_history,
8902 4232 $relevant_content,
8903 4233 $session_id,
8904 - $testing_data
4234 + $testing_data // Pass testing data
8905 4235 );
8906 4236 } else {
8907 4237 $response = $this->mxchat_generate_response_openai(
8908 4238 $selected_model,
@@ -8907,25 +4237,30 @@
8907 4237 $response = $this->mxchat_generate_response_openai(
8908 4238 $selected_model,
8909 4239 $api_key,
8910 4240 $conversation_history,
8911 - $relevant_content,
8912 - $session_id
4241 + $relevant_content
8913 4242 );
8914 4243 }
8915 4244 break;
8916 4245 }
8917 4246
4247 + // Check if the response is an error array from the provider-specific function
8918 4248 if (is_array($response) && isset($response['error'])) {
4249 + // Add testing data to error response if available
8919 4250 if ($testing_data !== null) {
8920 4251 $response['testing_data'] = $testing_data;
4252 + //error_log("MxChat Testing: Added testing data to provider error response");
8921 4253 }
8922 - return $response;
4254 + return $response; // Pass through the error with testing data
8923 4255 }
8924 4256
4257 + // For successful non-streaming responses, we don't add testing data here
4258 + // because it will be added in the main handler
8925 4259 return $response;
8926 4260
8927 4261 } catch (Exception $e) {
4262 + //error_log('MXChat Error: ' . $e->getMessage());
8928 4263 $error_response = [
8929 4264 'error' => sprintf(esc_html__('An error occurred: %s', 'mxchat'), esc_html($e->getMessage())),
8930 4265 'error_code' => 'system_exception',
8931 4266 'exception_details' => $e->getMessage()
@@ -8930,24 +4265,29 @@
8930 4265 'error_code' => 'system_exception',
8931 4266 'exception_details' => $e->getMessage()
8932 4267 ];
8933 4268
4269 + // Add testing data to exception response if available
8934 4270 if ($testing_data !== null) {
8935 4271 $error_response['testing_data'] = $testing_data;
4272 + //error_log("MxChat Testing: Added testing data to exception response");
8936 4273 }
8937 4274
8938 4275 return $error_response;
8939 4276 }
8940 4277 }
8941 -private function mxchat_generate_response_openrouter_stream($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
4278 +
4279 +private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
8942 4280 try {
8943 - $bot_id = $this->get_current_bot_id($session_id);
8944 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
4281 + // Get system prompt instructions from options
4282 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
8945 4283
4284 + // Ensure conversation_history is an array
8946 4285 if (!is_array($conversation_history)) {
8947 4286 $conversation_history = array();
8948 4287 }
8949 4288
4289 + // Format conversation history for OpenAI
8950 4290 $formatted_conversation = array();
8951 4291
8952 4292 $formatted_conversation[] = array(
8953 4293 'role' => 'system',
@@ -8959,9 +4299,9 @@
8959 4299 $role = $message['role'];
8960 4300 if ($role === 'bot' || $role === 'agent') {
8961 4301 $role = 'assistant';
8962 4302 }
8963 - if (!in_array($role, ['system', 'assistant', 'user'])) {
4303 + if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
8964 4304 $role = 'user';
8965 4305 }
8966 4306 $formatted_conversation[] = array(
8967 4307 'role' => $role,
@@ -8969,22 +4309,19 @@
8969 4309 );
8970 4310 }
8971 4311 }
8972 4312
4313 + // Check if we can actually stream
8973 4314 if (headers_sent() || !function_exists('curl_init')) {
8974 - $regular_response = $this->mxchat_generate_response_openrouter(
4315 + // Fallback to regular response with testing data
4316 + //error_log("MxChat: OpenAI streaming not possible, falling back to regular response");
4317 + $regular_response = $this->mxchat_generate_response_openai(
8975 4318 $selected_model,
8976 - $openrouter_api_key,
4319 + $api_key,
8977 4320 $conversation_history,
8978 - $relevant_content,
8979 - $session_id
4321 + $relevant_content
8980 4322 );
8981 4323
8982 - // Save bot response to transcript
8983 - if (!empty($regular_response) && !empty($session_id)) {
8984 - $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
8985 - }
8986 -
8987 4324 $response_data = [
8988 4325 'text' => $regular_response,
8989 4326 'html' => '',
8990 4327 'session_id' => $session_id
@@ -8991,8 +4328,9 @@
8991 4328 ];
8992 4329
8993 4330 if ($testing_data !== null) {
8994 4331 $response_data['testing_data'] = $testing_data;
4332 + //error_log("MxChat Testing: Added testing data to OpenAI fallback response");
8995 4333 }
8996 4334
8997 4335 header('Content-Type: application/json');
8998 4336 echo json_encode($response_data);
@@ -8998,8 +4336,9 @@
8998 4336 echo json_encode($response_data);
8999 4337 return true;
9000 4338 }
9001 4339
4340 + // Prepare the request body with stream: true
9002 4341 $body = json_encode([
9003 4342 'model' => $selected_model,
9004 4343 'messages' => $formatted_conversation,
9005 4344 'temperature' => 1,
@@ -9005,230 +4344,78 @@
9005 4344 'temperature' => 1,
9006 4345 'stream' => true
9007 4346 ]);
9008 4347
9009 - // V2 retry-on-initial-connect: setup_streaming_headers is now lazy-fired
9010 - // inside WRITEFUNCTION on first byte of a successful upstream.
9011 -
9012 - $captured_status_code = 0;
9013 - $captured_body_pre_stream = '';
9014 - $full_response = '';
4348 + // Use cURL for streaming support
4349 + $ch = curl_init();
4350 + curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
4351 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
4352 + curl_setopt($ch, CURLOPT_POST, true);
4353 + curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
4354 + curl_setopt($ch, CURLOPT_HTTPHEADER, array(
4355 + 'Content-Type: application/json',
4356 + 'Authorization: Bearer ' . $api_key
4357 + ));
4358 + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
4359 + curl_setopt($ch, CURLOPT_TIMEOUT, 60);
4360 +
4361 + $full_response = ''; // Accumulate full response for saving
9015 4362 $stream_started = false;
9016 - $buffer = '';
9017 - $errno = 0;
9018 - $last_curl_error = '';
9019 - $http_code = 0;
9020 - $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9021 - $backoff_ms = array(0, 750, 2000);
9022 -
9023 - for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9024 - if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9025 - usleep($backoff_ms[$attempt] * 1000);
4363 +
4364 + // Buffer control for real-time streaming
4365 + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, $testing_data) {
4366 + // Send testing data as the first event if available
4367 + if (!$stream_started && $testing_data !== null) {
4368 + echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
4369 + flush();
4370 + $stream_started = true;
4371 + //error_log("MxChat Testing: Sent testing data in OpenAI stream");
9026 4372 }
9027 -
9028 - $captured_status_code = 0;
9029 - $captured_body_pre_stream = '';
9030 - $full_response = '';
9031 - $stream_started = false;
9032 - $buffer = '';
9033 -
9034 - $ch = curl_init();
9035 - curl_setopt($ch, CURLOPT_URL, 'https://openrouter.ai/api/v1/chat/completions');
9036 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9037 - curl_setopt($ch, CURLOPT_POST, true);
9038 - curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9039 - curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9040 - 'Content-Type: application/json',
9041 - 'Authorization: Bearer ' . $openrouter_api_key,
9042 - 'HTTP-Referer: ' . home_url(),
9043 - 'X-Title: ' . get_bloginfo('name')
9044 - ));
9045 - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9046 - curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9047 -
9048 - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9049 - if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9050 - $captured_status_code = (int) $m[1];
4373 +
4374 + // Process each chunk of data
4375 + $lines = explode("\n", $data);
4376 +
4377 + foreach ($lines as $line) {
4378 + if (trim($line) === '' || strpos($line, 'data: ') !== 0) {
4379 + continue;
9051 4380 }
9052 - return strlen($header);
9053 - });
9054 -
9055 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9056 - if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9057 - $captured_body_pre_stream .= $data;
9058 - return strlen($data);
4381 +
4382 + $json_str = substr($line, 6); // Remove 'data: ' prefix
4383 +
4384 + if ($json_str === '[DONE]') {
4385 + echo "data: [DONE]\n\n";
4386 + flush();
4387 + continue;
9059 4388 }
9060 -
9061 - if (!$this->streaming_headers_sent) {
9062 - $this->setup_streaming_headers();
9063 - }
9064 -
9065 - if (!$stream_started && $testing_data !== null) {
9066 - echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
4389 +
4390 + $json = json_decode($json_str, true);
4391 + if (isset($json['choices'][0]['delta']['content'])) {
4392 + $content = $json['choices'][0]['delta']['content'];
4393 + $full_response .= $content; // Accumulate
4394 + // Send as SSE format
4395 + echo "data: " . json_encode(['content' => $content]) . "\n\n";
9067 4396 flush();
9068 - $stream_started = true;
9069 4397 }
9070 -
9071 - $buffer .= $data;
9072 - $lines = explode("\n", $buffer);
9073 - $buffer = array_pop($lines);
9074 -
9075 - foreach ($lines as $line) {
9076 - if (trim($line) === '') {
9077 - continue;
9078 - }
9079 - if (strpos($line, 'data: ') !== 0) {
9080 - continue;
9081 - }
9082 -
9083 - $json_str = substr($line, 6);
9084 -
9085 - if (trim($json_str) === '[DONE]') {
9086 - echo "data: [DONE]\n\n";
9087 - flush();
9088 - continue;
9089 - }
9090 -
9091 - $json = json_decode(trim($json_str), true);
9092 - if ($json && isset($json['choices'][0]['delta']['content'])) {
9093 - $content = $json['choices'][0]['delta']['content'];
9094 - $full_response .= $content;
9095 -
9096 - echo "data: " . json_encode(['content' => $content]) . "\n\n";
9097 - flush();
9098 - }
9099 - }
9100 -
9101 - return strlen($data);
9102 - });
9103 -
9104 - $response = curl_exec($ch);
9105 - $errno = curl_errno($ch);
9106 - $last_curl_error = curl_error($ch);
9107 - $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9108 - curl_close($ch);
9109 -
9110 - if (!$errno && $http_code === 200) {
9111 - break;
9112 4398 }
9113 -
9114 - $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9115 - $can_retry = !$this->streaming_headers_sent
9116 - && ($attempt + 1) < $max_attempts
9117 - && $is_transient;
9118 -
9119 - if (defined('WP_DEBUG') && WP_DEBUG) {
9120 - error_log(sprintf(
9121 - '[MxChat] openrouter_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9122 - $attempt + 1, $max_attempts, $http_code, $errno,
9123 - $is_transient ? 'yes' : 'no',
9124 - $can_retry ? 'Retrying.' : 'Giving up.'
9125 - ));
9126 - }
9127 -
9128 - if (!$can_retry) {
9129 - break;
9130 - }
9131 - }
9132 -
9133 - if (!$errno && $http_code === 200) {
9134 - if (!empty($full_response) && !empty($session_id)) {
9135 - $rag_context_for_storage = null;
9136 - $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9137 - $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9138 -
9139 - if ($has_rag_data || $has_action_data) {
9140 - $rag_context_for_storage = [];
9141 -
9142 - if ($has_rag_data) {
9143 - $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9144 - $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9145 - $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9146 - $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9147 - $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9148 - }
9149 -
9150 - if ($has_action_data) {
9151 - $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9152 - }
9153 - }
9154 - $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9155 - }
9156 - return true;
9157 - }
9158 -
9159 - return $this->mxchat_stream_emit_fallback(
9160 - 'openai',
9161 - $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
9162 - $session_id,
9163 - $testing_data
9164 - );
9165 -
9166 - } catch (Exception $e) {
9167 - return $this->mxchat_stream_emit_fallback(
9168 - 'openai',
9169 - $this->mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id),
9170 - $session_id,
9171 - $testing_data
9172 - );
9173 - }
9174 -}
9175 -private function mxchat_generate_response_openai_stream($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9176 - // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
9177 - // (replacement gpt-5.6-sol). Read-time rescue mirrors the non-streaming
9178 - // path (plan e46b8f).
9179 - if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
9180 - try {
9181 - $bot_id = $this->get_current_bot_id($session_id);
4399 +
4400 + return strlen($data);
4401 + });
9182 4402
9183 - // Get system prompt instructions using centralized function
9184 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
4403 + $response = curl_exec($ch);
4404 + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
9185 4405
9186 - // Ensure conversation_history is an array
9187 - if (!is_array($conversation_history)) {
9188 - $conversation_history = array();
9189 - }
9190 -
9191 - // Format conversation history for OpenAI
9192 - $formatted_conversation = array();
9193 -
9194 - $formatted_conversation[] = array(
9195 - 'role' => 'system',
9196 - 'content' => $system_prompt_instructions . " " . $relevant_content
9197 - );
9198 -
9199 - foreach ($conversation_history as $message) {
9200 - if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9201 - $role = $message['role'];
9202 - if ($role === 'bot' || $role === 'agent') {
9203 - $role = 'assistant';
9204 - }
9205 - if (!in_array($role, ['system', 'assistant', 'user', 'function', 'tool'])) {
9206 - $role = 'user';
9207 - }
9208 - $formatted_conversation[] = array(
9209 - 'role' => $role,
9210 - 'content' => $message['content']
9211 - );
9212 - }
9213 - }
9214 -
9215 - // Check if we can actually stream
9216 - if (headers_sent() || !function_exists('curl_init')) {
9217 - // Fallback to regular response with testing data
4406 + if (curl_errno($ch) || $http_code !== 200) {
4407 + curl_close($ch);
4408 +
4409 + // Fallback to regular response
4410 + //error_log("MxChat: OpenAI streaming failed, falling back");
9218 4411 $regular_response = $this->mxchat_generate_response_openai(
9219 4412 $selected_model,
9220 4413 $api_key,
9221 4414 $conversation_history,
9222 - $relevant_content,
9223 - $session_id
4415 + $relevant_content
9224 4416 );
9225 4417
9226 - // Save bot response to transcript
9227 - if (!empty($regular_response) && !empty($session_id)) {
9228 - $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
9229 - }
9230 -
9231 4418 $response_data = [
9232 4419 'text' => $regular_response,
9233 4420 'html' => '',
9234 4421 'session_id' => $session_id
@@ -9235,8 +4422,9 @@
9235 4422 ];
9236 4423
9237 4424 if ($testing_data !== null) {
9238 4425 $response_data['testing_data'] = $testing_data;
4426 + //error_log("MxChat Testing: Added testing data to OpenAI error fallback");
9239 4427 }
9240 4428
9241 4429 header('Content-Type: application/json');
9242 4430 echo json_encode($response_data);
@@ -9241,920 +4429,50 @@
9241 4429 header('Content-Type: application/json');
9242 4430 echo json_encode($response_data);
9243 4431 return true;
9244 4432 }
9245 -
9246 - // Build request body with optimal settings for fast streaming
9247 - $request_body = [
9248 - 'model' => $selected_model,
9249 - 'messages' => $formatted_conversation,
9250 - 'temperature' => 1,
9251 - 'stream' => true
9252 - ];
9253 -
9254 - // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
9255 - // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
9256 - $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
9257 - if ($effort !== null) {
9258 - $request_body['reasoning_effort'] = $effort;
4433 +
4434 + curl_close($ch);
4435 +
4436 + // Save the complete response to maintain chat persistence
4437 + if (!empty($full_response) && !empty($session_id)) {
4438 + $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
9259 4439 }
9260 -
9261 - $body = json_encode($request_body);
9262 -
9263 - // V2 retry-on-initial-connect: do NOT call setup_streaming_headers() here.
9264 - // It is now lazy-fired inside the WRITEFUNCTION on the first byte of a
9265 - // SUCCESSFUL upstream response, gated by the captured HTTP status.
9266 -
9267 - $captured_status_code = 0;
9268 - $captured_body_pre_stream = '';
9269 - $full_response = '';
9270 - $stream_started = false;
9271 - $buffer = '';
9272 - $errno = 0;
9273 - $last_curl_error = '';
9274 - $http_code = 0;
9275 - $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9276 - $backoff_ms = array(0, 750, 2000);
9277 -
9278 - for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9279 - if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9280 - usleep($backoff_ms[$attempt] * 1000);
9281 - }
9282 -
9283 - // Reset per-attempt capture state.
9284 - $captured_status_code = 0;
9285 - $captured_body_pre_stream = '';
9286 - $full_response = '';
9287 - $stream_started = false;
9288 - $buffer = '';
9289 -
9290 - $ch = curl_init();
9291 - curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/chat/completions');
9292 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9293 - curl_setopt($ch, CURLOPT_POST, true);
9294 - curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9295 - curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9296 - 'Content-Type: application/json',
9297 - 'Authorization: Bearer ' . $api_key
9298 - ));
9299 - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9300 - curl_setopt($ch, CURLOPT_TIMEOUT, 60);
9301 -
9302 - // Capture HTTP status as soon as response headers arrive — fires before WRITEFUNCTION.
9303 - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9304 - if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9305 - $captured_status_code = (int) $m[1];
9306 - }
9307 - return strlen($header);
9308 - });
9309 -
9310 - // Buffer control for real-time streaming
9311 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9312 - // V2 guard: if upstream returned non-200, buffer body for transient
9313 - // classification and DO NOT emit to client. Stream channel must NOT open.
9314 - if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9315 - $captured_body_pre_stream .= $data;
9316 - return strlen($data);
9317 - }
9318 -
9319 - // Lazy-fire streaming headers on first byte of a SUCCESSFUL upstream.
9320 - // After this point streaming_headers_sent === true → retry is structurally blocked.
9321 - if (!$this->streaming_headers_sent) {
9322 - $this->setup_streaming_headers();
9323 - }
9324 -
9325 - // Send testing data as the first event if available
9326 - if (!$stream_started && $testing_data !== null) {
9327 - echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
9328 - flush();
9329 - $stream_started = true;
9330 - }
9331 -
9332 - // CRITICAL FIX: Append new data to buffer
9333 - $buffer .= $data;
9334 -
9335 - // Process complete lines only
9336 - $lines = explode("\n", $buffer);
9337 -
9338 - // CRITICAL FIX: Keep the last incomplete line in the buffer
9339 - $buffer = array_pop($lines);
9340 -
9341 - foreach ($lines as $line) {
9342 - if (trim($line) === '') {
9343 - continue;
9344 - }
9345 - if (strpos($line, 'data: ') !== 0) {
9346 - continue;
9347 - }
9348 -
9349 - $json_str = substr($line, 6);
9350 -
9351 - if (trim($json_str) === '[DONE]') {
9352 - echo "data: [DONE]\n\n";
9353 - flush();
9354 - continue;
9355 - }
9356 -
9357 - $json = json_decode(trim($json_str), true);
9358 - if ($json && isset($json['choices'][0]['delta']['content'])) {
9359 - $content = $json['choices'][0]['delta']['content'];
9360 - $full_response .= $content;
9361 -
9362 - echo "data: " . json_encode(['content' => $content]) . "\n\n";
9363 - flush();
9364 - }
9365 - }
9366 -
9367 - return strlen($data);
9368 - });
9369 -
9370 - $response = curl_exec($ch);
9371 - $errno = curl_errno($ch);
9372 - $last_curl_error = curl_error($ch);
9373 - $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9374 - curl_close($ch);
9375 -
9376 - if (!$errno && $http_code === 200) {
9377 - break; // Happy path — WRITEFUNCTION already streamed everything.
9378 - }
9379 -
9380 - $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9381 - $can_retry = !$this->streaming_headers_sent
9382 - && ($attempt + 1) < $max_attempts
9383 - && $is_transient;
9384 -
9385 - if (defined('WP_DEBUG') && WP_DEBUG) {
9386 - error_log(sprintf(
9387 - '[MxChat] openai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9388 - $attempt + 1, $max_attempts, $http_code, $errno,
9389 - $is_transient ? 'yes' : 'no',
9390 - $can_retry ? 'Retrying.' : 'Giving up.'
9391 - ));
9392 - }
9393 -
9394 - if (!$can_retry) {
9395 - break;
9396 - }
9397 - }
9398 -
9399 - // Post-loop branch.
9400 - if (!$errno && $http_code === 200) {
9401 - // Happy path — save the complete response to maintain chat persistence.
9402 - if (!empty($full_response) && !empty($session_id)) {
9403 - $rag_context_for_storage = null;
9404 - $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
9405 - $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
9406 -
9407 - if ($has_rag_data || $has_action_data) {
9408 - $rag_context_for_storage = [];
9409 -
9410 - if ($has_rag_data) {
9411 - $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
9412 - $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
9413 - $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
9414 - $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
9415 - $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
9416 - }
9417 -
9418 - if ($has_action_data) {
9419 - $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
9420 - }
9421 - }
9422 - $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
9423 - }
9424 -
9425 - return true;
9426 - }
9427 -
9428 - // Failure path — branch on whether SSE channel was opened.
9429 - return $this->mxchat_stream_emit_fallback(
9430 - 'openai',
9431 - $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9432 - $session_id,
9433 - $testing_data
9434 - );
9435 -
4440 +
4441 + return true; // Indicate streaming completed successfully
4442 +
9436 4443 } catch (Exception $e) {
9437 - return $this->mxchat_stream_emit_fallback(
9438 - 'openai',
9439 - $this->mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id),
9440 - $session_id,
9441 - $testing_data
4444 + //error_log("MxChat OpenAI streaming exception: " . $e->getMessage());
4445 +
4446 + // Fallback to regular response
4447 + $regular_response = $this->mxchat_generate_response_openai(
4448 + $selected_model,
4449 + $api_key,
4450 + $conversation_history,
4451 + $relevant_content
9442 4452 );
9443 - }
9444 -}
9445 -
9446 -/**
9447 - * Shared fallback emitter for streaming chat functions. Two outcomes:
9448 - * - streaming_headers_sent === true: SSE channel is open. Emit fallback content
9449 - * as `data: {...}\n\n` + `data: [DONE]\n\n` so the widget renders it as a
9450 - * normal bot bubble. Transcript row is persisted.
9451 - * - streaming_headers_sent === false: SSE channel never opened (retries
9452 - * exhausted on initial connect). Emit a clean JSON response — the path
9453 - * the widget would normally hit if streaming wasn't even attempted.
9454 - *
9455 - * Used by all six *_stream functions after their per-attempt retry loop.
9456 - */
9457 -private function mxchat_stream_emit_fallback($provider_hint, $regular_response, $session_id, $testing_data = null) {
9458 - $is_error_array = is_array($regular_response) && isset($regular_response['error']);
9459 -
9460 - if ($this->streaming_headers_sent) {
9461 - if ($is_error_array) {
9462 - echo "data: " . json_encode([
9463 - 'error' => true,
9464 - 'error_message' => $regular_response['error'],
9465 - 'error_code' => $regular_response['error_code'] ?? 'api_error',
9466 - 'text' => $regular_response['error'],
9467 - 'message' => $regular_response['error']
9468 - ]) . "\n\n";
9469 - echo "data: [DONE]\n\n";
9470 - flush();
9471 - return true;
4453 +
4454 + $response_data = [
4455 + 'text' => $regular_response,
4456 + 'html' => '',
4457 + 'session_id' => $session_id
4458 + ];
4459 +
4460 + if ($testing_data !== null) {
4461 + $response_data['testing_data'] = $testing_data;
4462 + //error_log("MxChat Testing: Added testing data to OpenAI exception fallback");
9472 4463 }
9473 - $fallback_message = (string) $regular_response;
9474 - if (!empty($fallback_message) && !empty($session_id)) {
9475 - $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9476 - }
9477 - echo "data: " . json_encode(['content' => $fallback_message]) . "\n\n";
9478 - echo "data: [DONE]\n\n";
9479 - flush();
9480 - return true;
9481 - }
9482 -
9483 - // SSE channel never opened — clean JSON fallback.
9484 - if ($is_error_array) {
4464 +
9485 4465 header('Content-Type: application/json');
9486 - echo json_encode(array(
9487 - 'error' => true,
9488 - 'error_message' => $regular_response['error'],
9489 - 'error_code' => $regular_response['error_code'] ?? 'api_error',
9490 - 'text' => $regular_response['error'],
9491 - 'message' => $regular_response['error'],
9492 - ));
4466 + echo json_encode($response_data);
9493 4467 return true;
9494 4468 }
9495 -
9496 - $fallback_message = (string) $regular_response;
9497 - if (!empty($fallback_message) && !empty($session_id)) {
9498 - $this->mxchat_save_chat_message($session_id, 'bot', $fallback_message);
9499 - }
9500 - $response_data = array(
9501 - 'text' => $fallback_message,
9502 - 'html' => '',
9503 - 'session_id' => $session_id,
9504 - );
9505 - if ($testing_data !== null) {
9506 - $response_data['testing_data'] = $testing_data;
9507 - }
9508 - header('Content-Type: application/json');
9509 - echo json_encode($response_data);
9510 - return true;
9511 4469 }
9512 -
9513 -/**
9514 - * Resolve custom (OpenAI-compatible) provider config from settings.
9515 - * Returns ['base_url','api_key','model','auth_scheme','api_version','chat_url','headers'].
9516 - */
9517 -private function mxchat_resolve_custom_provider() {
9518 - $base_url = isset($this->options['custom_provider_base_url']) ? rtrim(trim((string) $this->options['custom_provider_base_url']), '/') : '';
9519 - $api_key = isset($this->options['custom_provider_api_key']) ? trim((string) $this->options['custom_provider_api_key']) : '';
9520 - $model = isset($this->options['custom_provider_model']) ? trim((string) $this->options['custom_provider_model']) : '';
9521 - $auth_scheme = isset($this->options['custom_provider_auth_scheme']) ? $this->options['custom_provider_auth_scheme'] : 'bearer';
9522 - $api_version = isset($this->options['custom_provider_api_version']) ? trim((string) $this->options['custom_provider_api_version']) : '';
9523 -
9524 - $chat_url = $base_url . '/chat/completions';
9525 - if (!empty($api_version)) {
9526 - $chat_url .= (strpos($chat_url, '?') === false ? '?' : '&') . 'api-version=' . rawurlencode($api_version);
9527 - }
9528 -
9529 - $headers = array('Content-Type: application/json');
9530 - if (!empty($api_key)) {
9531 - if ($auth_scheme === 'api-key') {
9532 - $headers[] = 'api-key: ' . $api_key;
9533 - } else {
9534 - $headers[] = 'Authorization: Bearer ' . $api_key;
9535 - }
9536 - }
9537 -
9538 - return array(
9539 - 'base_url' => $base_url,
9540 - 'api_key' => $api_key,
9541 - 'model' => $model !== '' ? $model : 'default',
9542 - 'auth_scheme' => $auth_scheme,
9543 - 'api_version' => $api_version,
9544 - 'chat_url' => $chat_url,
9545 - 'headers' => $headers,
9546 - );
9547 -}
9548 -
9549 -/**
9550 - * Streaming chat completion against an OpenAI-compatible custom provider
9551 - * (Ollama, LM Studio, vLLM, llama.cpp, Azure OpenAI, etc.).
9552 - * Mirrors mxchat_generate_response_openai_stream but with parameterized URL/auth/model.
9553 - */
9554 -private function mxchat_generate_response_custom_stream($selected_model, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
4470 +private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
9555 4471 try {
9556 - $cfg = $this->mxchat_resolve_custom_provider();
9557 - if (empty($cfg['base_url'])) {
9558 - return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9559 - }
4472 + // Get system prompt instructions from options
4473 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
9560 4474
9561 - $bot_id = $this->get_current_bot_id($session_id);
9562 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9563 - if (!is_array($conversation_history)) {
9564 - $conversation_history = array();
9565 - }
9566 -
9567 - $formatted_conversation = array();
9568 - $formatted_conversation[] = array(
9569 - 'role' => 'system',
9570 - 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9571 - );
9572 - foreach ($conversation_history as $message) {
9573 - if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9574 - $role = $message['role'];
9575 - if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9576 - if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9577 - $formatted_conversation[] = array('role' => $role, 'content' => $message['content']);
9578 - }
9579 - }
9580 -
9581 - if (headers_sent() || !function_exists('curl_init')) {
9582 - // No streaming capability — fall through to non-stream wrapper
9583 - $regular = $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content);
9584 - if (!empty($regular) && !empty($session_id) && is_string($regular)) {
9585 - $this->mxchat_save_chat_message($session_id, 'bot', $regular);
9586 - }
9587 - $response_data = array('text' => is_string($regular) ? $regular : '', 'html' => '', 'session_id' => $session_id);
9588 - if ($testing_data !== null) { $response_data['testing_data'] = $testing_data; }
9589 - header('Content-Type: application/json');
9590 - echo json_encode($response_data);
9591 - return true;
9592 - }
9593 -
9594 - $request_body = array(
9595 - 'model' => $cfg['model'],
9596 - 'messages' => $formatted_conversation,
9597 - 'stream' => true,
9598 - );
9599 - $body = json_encode($request_body);
9600 -
9601 - // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
9602 -
9603 - $captured_status_code = 0;
9604 - $captured_body_pre_stream = '';
9605 - $full_response = '';
9606 - $stream_started = false;
9607 - $buffer = '';
9608 - $errno = 0;
9609 - $http_code = 0;
9610 - $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
9611 - $backoff_ms = array(0, 750, 2000);
9612 -
9613 - for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
9614 - if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
9615 - usleep($backoff_ms[$attempt] * 1000);
9616 - }
9617 -
9618 - $captured_status_code = 0;
9619 - $captured_body_pre_stream = '';
9620 - $full_response = '';
9621 - $stream_started = false;
9622 - $buffer = '';
9623 -
9624 - $ch = curl_init();
9625 - curl_setopt($ch, CURLOPT_URL, $cfg['chat_url']);
9626 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9627 - curl_setopt($ch, CURLOPT_POST, true);
9628 - curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
9629 - curl_setopt($ch, CURLOPT_HTTPHEADER, $cfg['headers']);
9630 - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9631 - curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9632 -
9633 - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
9634 - if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
9635 - $captured_status_code = (int) $m[1];
9636 - }
9637 - return strlen($header);
9638 - });
9639 -
9640 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
9641 - if ($captured_status_code !== 0 && $captured_status_code !== 200) {
9642 - $captured_body_pre_stream .= $data;
9643 - return strlen($data);
9644 - }
9645 -
9646 - if (!$this->streaming_headers_sent) {
9647 - $this->setup_streaming_headers();
9648 - }
9649 -
9650 - if (!$stream_started && $testing_data !== null) {
9651 - echo "data: " . json_encode(array('testing_data' => $testing_data)) . "\n\n";
9652 - flush();
9653 - $stream_started = true;
9654 - }
9655 - $buffer .= $data;
9656 - $lines = explode("\n", $buffer);
9657 - $buffer = array_pop($lines);
9658 - foreach ($lines as $line) {
9659 - if (trim($line) === '') { continue; }
9660 - if (strpos($line, 'data: ') !== 0) { continue; }
9661 - $json_str = substr($line, 6);
9662 - if (trim($json_str) === '[DONE]') {
9663 - echo "data: [DONE]\n\n";
9664 - flush();
9665 - continue;
9666 - }
9667 - $json = json_decode(trim($json_str), true);
9668 - if ($json && isset($json['choices'][0]['delta']['content'])) {
9669 - $content = $json['choices'][0]['delta']['content'];
9670 - $full_response .= $content;
9671 - echo "data: " . json_encode(array('content' => $content)) . "\n\n";
9672 - flush();
9673 - }
9674 - }
9675 - return strlen($data);
9676 - });
9677 -
9678 - $response = curl_exec($ch);
9679 - $errno = curl_errno($ch);
9680 - $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
9681 - curl_close($ch);
9682 -
9683 - if (!$errno && $http_code === 200) {
9684 - break;
9685 - }
9686 -
9687 - $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
9688 - $can_retry = !$this->streaming_headers_sent
9689 - && ($attempt + 1) < $max_attempts
9690 - && $is_transient;
9691 -
9692 - if (defined('WP_DEBUG') && WP_DEBUG) {
9693 - error_log(sprintf(
9694 - '[MxChat] custom_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
9695 - $attempt + 1, $max_attempts, $http_code, $errno,
9696 - $is_transient ? 'yes' : 'no',
9697 - $can_retry ? 'Retrying.' : 'Giving up.'
9698 - ));
9699 - }
9700 -
9701 - if (!$can_retry) {
9702 - break;
9703 - }
9704 - }
9705 -
9706 - if (!$errno && $http_code === 200) {
9707 - if (!empty($full_response) && !empty($session_id)) {
9708 - $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
9709 - }
9710 - return true;
9711 - }
9712 -
9713 - return $this->mxchat_stream_emit_fallback(
9714 - 'openai',
9715 - $this->mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content),
9716 - $session_id,
9717 - $testing_data
9718 - );
9719 -
9720 - } catch (Exception $e) {
9721 - return array('error' => sprintf(esc_html__('Custom provider error: %s', 'mxchat'), $e->getMessage()), 'error_code' => 'custom_provider_exception');
9722 - }
9723 -}
9724 -
9725 -/**
9726 - * Non-streaming chat completion against a custom OpenAI-compatible provider.
9727 - * Returns string content on success, array['error'=>...] on failure.
9728 - */
9729 -private function mxchat_generate_response_custom($selected_model, $conversation_history, $relevant_content) {
9730 - $cfg = $this->mxchat_resolve_custom_provider();
9731 - if (empty($cfg['base_url'])) {
9732 - return array('error' => esc_html__('Custom provider Base URL is not configured.', 'mxchat'), 'error_code' => 'missing_custom_provider_base_url');
9733 - }
9734 -
9735 - $bot_id = $this->get_current_bot_id(null);
9736 - $system_prompt_instructions = $this->get_system_instructions($bot_id, null);
9737 - if (!is_array($conversation_history)) {
9738 - $conversation_history = array();
9739 - }
9740 -
9741 - $messages = array(array(
9742 - 'role' => 'system',
9743 - 'content' => $system_prompt_instructions . ' ' . $relevant_content,
9744 - ));
9745 - foreach ($conversation_history as $message) {
9746 - if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9747 - $role = $message['role'];
9748 - if ($role === 'bot' || $role === 'agent') { $role = 'assistant'; }
9749 - if (!in_array($role, array('system', 'assistant', 'user', 'function', 'tool'))) { $role = 'user'; }
9750 - $messages[] = array('role' => $role, 'content' => $message['content']);
9751 - }
9752 - }
9753 -
9754 - $headers_assoc = array('Content-Type' => 'application/json');
9755 - if (!empty($cfg['api_key'])) {
9756 - if ($cfg['auth_scheme'] === 'api-key') {
9757 - $headers_assoc['api-key'] = $cfg['api_key'];
9758 - } else {
9759 - $headers_assoc['Authorization'] = 'Bearer ' . $cfg['api_key'];
9760 - }
9761 - }
9762 -
9763 - $response = $this->mxchat_provider_call_with_retry($cfg['chat_url'], array(
9764 - 'headers' => $headers_assoc,
9765 - 'body' => wp_json_encode(array(
9766 - 'model' => $cfg['model'],
9767 - 'messages' => $messages,
9768 - )),
9769 - 'timeout' => 120,
9770 - ), 'openai');
9771 -
9772 - if (is_wp_error($response)) {
9773 - return array('error' => sprintf(esc_html__('Custom provider request failed: %s', 'mxchat'), $response->get_error_message()), 'error_code' => 'custom_provider_network_error');
9774 - }
9775 - $code = (int) wp_remote_retrieve_response_code($response);
9776 - if ($code < 200 || $code >= 300) {
9777 - return array('error' => sprintf(esc_html__('Custom provider returned HTTP %d.', 'mxchat'), $code), 'error_code' => 'custom_provider_http_error');
9778 - }
9779 - $body = json_decode(wp_remote_retrieve_body($response), true);
9780 - if (isset($body['choices'][0]['message']['content'])) {
9781 - return (string) $body['choices'][0]['message']['content'];
9782 - }
9783 - return array('error' => esc_html__('Custom provider returned an unexpected response shape.', 'mxchat'), 'error_code' => 'custom_provider_response_shape');
9784 -}
9785 -
9786 -/**
9787 - * Generate response using OpenAI Responses API with web search tool
9788 - * This uses the newer Responses API which supports web search functionality
9789 - */
9790 -private function mxchat_generate_response_openai_web_search($selected_model, $api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null, $streaming = false) {
9791 - // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
9792 - // (replacement gpt-5.6-sol). Read-time rescue mirrors the chat paths
9793 - // (plan e46b8f).
9794 - if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
9795 - try {
9796 - $bot_id = $this->get_current_bot_id($session_id);
9797 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
9798 -
9799 - if (!is_array($conversation_history)) {
9800 - $conversation_history = array();
9801 - }
9802 -
9803 - // Build the input for Responses API
9804 - // The Responses API uses a different format - we need to construct the input properly
9805 - $input_parts = [];
9806 -
9807 - // Add system instructions as context
9808 - $system_context = $system_prompt_instructions . "\n\n" . $relevant_content;
9809 -
9810 - // Build conversation as input items for Responses API
9811 - foreach ($conversation_history as $message) {
9812 - if (is_array($message) && isset($message['role']) && isset($message['content'])) {
9813 - $role = $message['role'];
9814 - if ($role === 'bot' || $role === 'agent') {
9815 - $role = 'assistant';
9816 - }
9817 - if (!in_array($role, ['assistant', 'user'])) {
9818 - $role = 'user';
9819 - }
9820 - $input_parts[] = [
9821 - 'type' => 'message',
9822 - 'role' => $role,
9823 - 'content' => $message['content']
9824 - ];
9825 - }
9826 - }
9827 -
9828 - // Build request body for Responses API
9829 - $request_body = [
9830 - 'model' => $selected_model,
9831 - 'input' => $input_parts,
9832 - 'instructions' => $system_context,
9833 - 'stream' => $streaming
9834 - ];
9835 -
9836 - // Only add web search tool if web search is enabled in settings
9837 - $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
9838 - if ($web_search_enabled) {
9839 - $request_body['tools'] = [
9840 - ['type' => 'web_search']
9841 - ];
9842 - }
9843 -
9844 - // reasoning.effort — sourced from the core model catalog (plan-dcb71c),
9845 - // 'websearch' surface; frozen inline ladder in mxchat_reasoning_effort_fallback().
9846 - $effort = $this->mxchat_reasoning_effort_for($selected_model, 'websearch');
9847 - if ($effort !== null) {
9848 - $request_body['reasoning'] = ['effort' => $effort];
9849 - }
9850 -
9851 - //error_log("MXCHAT WEB SEARCH: Request body: " . json_encode($request_body));
9852 -
9853 - if ($streaming) {
9854 - return $this->mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data);
9855 - } else {
9856 - return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9857 - }
9858 -
9859 - } catch (Exception $e) {
9860 - //error_log("MXCHAT WEB SEARCH ERROR: " . $e->getMessage());
9861 - return [
9862 - 'error' => sprintf(esc_html__('Web search error: %s', 'mxchat'), esc_html($e->getMessage())),
9863 - 'error_code' => 'web_search_exception'
9864 - ];
9865 - }
9866 -}
9867 -
9868 -/**
9869 - * Handle non-streaming web search response
9870 - */
9871 -private function mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9872 - $request_body['stream'] = false;
9873 -
9874 - $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/responses', array(
9875 - 'headers' => array(
9876 - 'Authorization' => 'Bearer ' . $api_key,
9877 - 'Content-Type' => 'application/json'
9878 - ),
9879 - 'body' => json_encode($request_body),
9880 - 'timeout' => 90
9881 - ), 'openai');
9882 -
9883 - if (is_wp_error($response)) {
9884 - //error_log("MXCHAT WEB SEARCH ERROR: WP Error: " . $response->get_error_message());
9885 - return [
9886 - 'error' => esc_html__('Failed to connect to OpenAI web search API', 'mxchat'),
9887 - 'error_code' => 'web_search_connection_error'
9888 - ];
9889 - }
9890 -
9891 - $response_code = wp_remote_retrieve_response_code($response);
9892 - $response_body = wp_remote_retrieve_body($response);
9893 -
9894 - //error_log("MXCHAT WEB SEARCH: Response code: " . $response_code);
9895 - //error_log("MXCHAT WEB SEARCH: Response body (first 2000): " . substr($response_body, 0, 2000));
9896 -
9897 - if ($response_code !== 200) {
9898 - $error_data = json_decode($response_body, true);
9899 - $error_message = $this->extract_provider_error($error_data, 'Unknown API error');
9900 - return [
9901 - 'error' => sprintf(esc_html__('OpenAI API error: %s', 'mxchat'), esc_html($error_message)),
9902 - 'error_code' => 'web_search_api_error'
9903 - ];
9904 - }
9905 -
9906 - $result = json_decode($response_body, true);
9907 -
9908 - if (json_last_error() !== JSON_ERROR_NONE) {
9909 - return [
9910 - 'error' => esc_html__('Invalid response from OpenAI', 'mxchat'),
9911 - 'error_code' => 'web_search_json_error'
9912 - ];
9913 - }
9914 -
9915 - // Extract the response text and citations from Responses API format
9916 - $output_text = '';
9917 - $citations = [];
9918 -
9919 - if (isset($result['output'])) {
9920 - foreach ($result['output'] as $output_item) {
9921 - if ($output_item['type'] === 'message' && isset($output_item['content'])) {
9922 - foreach ($output_item['content'] as $content_item) {
9923 - if ($content_item['type'] === 'output_text') {
9924 - $output_text .= $content_item['text'];
9925 -
9926 - // Extract citations/annotations
9927 - if (isset($content_item['annotations'])) {
9928 - foreach ($content_item['annotations'] as $annotation) {
9929 - if ($annotation['type'] === 'url_citation') {
9930 - $citations[] = [
9931 - 'url' => $annotation['url'],
9932 - 'title' => $annotation['title'] ?? ''
9933 - ];
9934 - }
9935 - }
9936 - }
9937 - }
9938 - }
9939 - }
9940 - }
9941 - }
9942 -
9943 - // If we have citations, append them to the response
9944 - if (!empty($citations)) {
9945 - $output_text .= "\n\n**Sources:**\n";
9946 - $seen_urls = [];
9947 - foreach ($citations as $citation) {
9948 - if (!in_array($citation['url'], $seen_urls)) {
9949 - $seen_urls[] = $citation['url'];
9950 - $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
9951 - $output_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
9952 - }
9953 - }
9954 - }
9955 -
9956 - // Transcript save is handled by the main handler (mxchat_handle_chat_request)
9957 - // which includes rag_context for the "sources" link in transcripts.
9958 -
9959 - // plan-4aa8e5: a 200 whose output carries no output_text (status
9960 - // "incomplete" with max_output_tokens exhausted, content-filter-emptied
9961 - // output, shape drift) previously fell through and returned '' — a
9962 - // silent empty bot bubble. This is the DEFAULT model path
9963 - // (the default OpenAI chat model routes through /v1/responses).
9964 - if (trim($output_text) === '') {
9965 - return $this->mxchat_empty_completion_error($result, 'OpenAI');
9966 - }
9967 -
9968 - return $output_text;
9969 -}
9970 -
9971 -/**
9972 - * Handle streaming web search response using Responses API
9973 - */
9974 -private function mxchat_web_search_streaming_response($request_body, $api_key, $session_id, $testing_data) {
9975 - $request_body['stream'] = true;
9976 -
9977 - // Check if we can stream
9978 - if (headers_sent() || !function_exists('curl_init')) {
9979 - // Fallback to non-streaming
9980 - return $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data);
9981 - }
9982 -
9983 - // Setup streaming headers
9984 - $this->setup_streaming_headers();
9985 -
9986 - $ch = curl_init();
9987 - curl_setopt($ch, CURLOPT_URL, 'https://api.openai.com/v1/responses');
9988 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
9989 - curl_setopt($ch, CURLOPT_POST, true);
9990 - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($request_body));
9991 - curl_setopt($ch, CURLOPT_HTTPHEADER, array(
9992 - 'Content-Type: application/json',
9993 - 'Authorization: Bearer ' . $api_key
9994 - ));
9995 - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
9996 - curl_setopt($ch, CURLOPT_TIMEOUT, 120);
9997 -
9998 - $full_response = '';
9999 - $stream_started = false;
10000 - $buffer = '';
10001 - $citations = [];
10002 - $empty_error_emitted = false;
10003 -
10004 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$citations, &$empty_error_emitted, $testing_data) {
10005 - // Send testing data as first event if available
10006 - if (!$stream_started && $testing_data !== null) {
10007 - echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10008 - flush();
10009 - $stream_started = true;
10010 - }
10011 -
10012 - $buffer .= $data;
10013 - $lines = explode("\n", $buffer);
10014 - $buffer = array_pop($lines);
10015 -
10016 - foreach ($lines as $line) {
10017 - if (trim($line) === '') continue;
10018 - if (strpos($line, 'data: ') !== 0) continue;
10019 -
10020 - $json_str = substr($line, 6);
10021 -
10022 - if (trim($json_str) === '[DONE]') {
10023 - // Append citations if we have any
10024 - if (!empty($citations)) {
10025 - $citation_text = "\n\n**Sources:**\n";
10026 - $seen_urls = [];
10027 - foreach ($citations as $citation) {
10028 - if (!in_array($citation['url'], $seen_urls)) {
10029 - $seen_urls[] = $citation['url'];
10030 - $title = !empty($citation['title']) ? $citation['title'] : $citation['url'];
10031 - $citation_text .= "- [" . $title . "](" . $citation['url'] . ")\n";
10032 - }
10033 - }
10034 - echo "data: " . json_encode(['content' => $citation_text]) . "\n\n";
10035 - $full_response .= $citation_text;
10036 - flush();
10037 - }
10038 - // plan-4aa8e5: zero deltas streamed → say so instead of
10039 - // closing a silent empty bubble (client renders text events).
10040 - if (trim($full_response) === '' && !$empty_error_emitted) {
10041 - $empty_error_emitted = true;
10042 - echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
10043 - }
10044 - echo "data: [DONE]\n\n";
10045 - flush();
10046 - continue;
10047 - }
10048 -
10049 - $json = json_decode(trim($json_str), true);
10050 - if (!$json) continue;
10051 -
10052 - // Handle Responses API streaming events
10053 - // The format is different from Chat Completions
10054 - if (isset($json['type'])) {
10055 - switch ($json['type']) {
10056 - case 'response.output_text.delta':
10057 - // Text content delta
10058 - if (isset($json['delta'])) {
10059 - $content = $json['delta'];
10060 - $full_response .= $content;
10061 - echo "data: " . json_encode(['content' => $content]) . "\n\n";
10062 - flush();
10063 - }
10064 - break;
10065 -
10066 - case 'response.output_item.done':
10067 - // Check for citations in completed items
10068 - if (isset($json['item']['content'])) {
10069 - foreach ($json['item']['content'] as $content_item) {
10070 - if (isset($content_item['annotations'])) {
10071 - foreach ($content_item['annotations'] as $annotation) {
10072 - if ($annotation['type'] === 'url_citation') {
10073 - $citations[] = [
10074 - 'url' => $annotation['url'],
10075 - 'title' => $annotation['title'] ?? ''
10076 - ];
10077 - }
10078 - }
10079 - }
10080 - }
10081 - }
10082 - break;
10083 - }
10084 - }
10085 - }
10086 -
10087 - return strlen($data);
10088 - });
10089 -
10090 - $response = curl_exec($ch);
10091 - $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
10092 -
10093 - if (curl_errno($ch) || $http_code !== 200) {
10094 - $curl_error = curl_error($ch);
10095 - curl_close($ch);
10096 -
10097 - //error_log("MXCHAT WEB SEARCH STREAM ERROR: HTTP $http_code, cURL error: $curl_error");
10098 -
10099 - return $this->mxchat_stream_emit_fallback(
10100 - 'web_search',
10101 - $this->mxchat_web_search_non_streaming_response($request_body, $api_key, $session_id, $testing_data),
10102 - $session_id,
10103 - $testing_data
10104 - );
10105 - }
10106 -
10107 - curl_close($ch);
10108 -
10109 - // plan-4aa8e5: the Responses API can end its stream via typed events
10110 - // without a [DONE] line — if nothing was streamed at all, close out with
10111 - // the empty-completion message instead of leaving a silent bubble.
10112 - if (trim($full_response) === '' && !$empty_error_emitted) {
10113 - echo "data: " . json_encode(['content' => esc_html__('The AI provider returned an empty response. Please try again.', 'mxchat')]) . "\n\n";
10114 - echo "data: [DONE]\n\n";
10115 - flush();
10116 - }
10117 -
10118 - // Save the complete response with RAG context so the "sources" link
10119 - // appears in transcripts — mirrors the pattern used by Claude/OpenAI streaming.
10120 - if (!empty($full_response) && !empty($session_id)) {
10121 - $rag_context_for_storage = null;
10122 - $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10123 - $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10124 -
10125 - if ($has_rag_data || $has_action_data) {
10126 - $rag_context_for_storage = [];
10127 -
10128 - if ($has_rag_data) {
10129 - $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10130 - $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10131 - $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10132 - $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10133 - $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10134 - }
10135 -
10136 - if ($has_action_data) {
10137 - $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10138 - }
10139 - }
10140 - $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
10141 - }
10142 -
10143 - return true;
10144 -}
10145 -
10146 -private function mxchat_generate_response_claude_stream($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10147 - // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
10148 - // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
10149 - if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
10150 - elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
10151 - try {
10152 - // Get bot ID from session or request
10153 - $bot_id = $this->get_current_bot_id($session_id);
10154 -
10155 - // Get system prompt instructions using centralized function
10156 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10157 4475 // Ensure conversation_history is an array
10158 4476 if (!is_array($conversation_history)) {
10159 4477 $conversation_history = array();
10160 4478 }
@@ -10186,9 +4504,9 @@
10186 4504 'content' => $relevant_content
10187 4505 ];
10188 4506
10189 4507 // Prepare the request body with stream: true
10190 - $payload = [
4508 + $body = json_encode([
10191 4509 'model' => $selected_model,
10192 4510 'messages' => $conversation_history,
10193 4511 'max_tokens' => 1000,
10194 4512 'temperature' => 0.8,
@@ -10193,11 +4511,9 @@
10193 4511 'max_tokens' => 1000,
10194 4512 'temperature' => 0.8,
10195 4513 'system' => $system_prompt_instructions,
10196 4514 'stream' => true
10197 - ];
10198 - if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
10199 - $body = json_encode($payload);
4515 + ]);
10200 4516
10201 4517 // Check if we can actually stream (headers not sent, etc.)
10202 4518 if (headers_sent() || !function_exists('curl_init')) {
10203 4519 // Fallback to regular response with testing data
@@ -10205,17 +4521,11 @@
10205 4521 $regular_response = $this->mxchat_generate_response_claude(
10206 4522 $selected_model,
10207 4523 $claude_api_key,
10208 4524 array_slice($conversation_history, 0, -1), // Remove the added content
10209 - $relevant_content,
10210 - $session_id
4525 + $relevant_content
10211 4526 );
10212 4527
10213 - // Save bot response to transcript
10214 - if (!empty($regular_response) && !empty($session_id)) {
10215 - $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10216 - }
10217 -
10218 4528 // Return as JSON with testing data
10219 4529 $response_data = [
10220 4530 'text' => $regular_response,
10221 4531 'html' => '',
@@ -10234,197 +4544,162 @@
10234 4544 echo json_encode($response_data);
10235 4545 return true; // Indicate we handled the response
10236 4546 }
10237 4547
10238 - // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
4548 + // Use cURL for streaming support
4549 + $ch = curl_init();
4550 + curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
4551 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
4552 + curl_setopt($ch, CURLOPT_POST, true);
4553 + curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
4554 + curl_setopt($ch, CURLOPT_HTTPHEADER, array(
4555 + 'Content-Type: application/json',
4556 + 'x-api-key: ' . $claude_api_key,
4557 + 'anthropic-version: 2023-06-01'
4558 + ));
4559 + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
4560 + curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10239 4561
10240 - $captured_status_code = 0;
10241 - $captured_body_pre_stream = '';
10242 - $full_response = '';
4562 + $full_response = ''; // Accumulate full response for saving
10243 4563 $stream_started = false;
10244 - $buffer = '';
10245 - $errno = 0;
10246 - $http_code = 0;
10247 - $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10248 - $backoff_ms = array(0, 750, 2000);
10249 4564
10250 - for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10251 - if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10252 - usleep($backoff_ms[$attempt] * 1000);
4565 + // Buffer control for real-time streaming
4566 + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, $testing_data) {
4567 + // Send testing data as the first event if available
4568 + if (!$stream_started && $testing_data !== null) {
4569 + echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
4570 + flush();
4571 + $stream_started = true;
4572 + //error_log("MxChat Testing: Sent testing data in Claude stream");
10253 4573 }
4574 +
4575 + // Process each chunk of data
4576 + $lines = explode("\n", $data);
10254 4577
10255 - $captured_status_code = 0;
10256 - $captured_body_pre_stream = '';
10257 - $full_response = '';
10258 - $stream_started = false;
10259 - $buffer = '';
10260 -
10261 - $ch = curl_init();
10262 - curl_setopt($ch, CURLOPT_URL, 'https://api.anthropic.com/v1/messages');
10263 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10264 - curl_setopt($ch, CURLOPT_POST, true);
10265 - curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10266 - curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10267 - 'Content-Type: application/json',
10268 - 'x-api-key: ' . $claude_api_key,
10269 - 'anthropic-version: 2023-06-01'
10270 - ));
10271 - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10272 - curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10273 -
10274 - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10275 - if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10276 - $captured_status_code = (int) $m[1];
4578 + foreach ($lines as $line) {
4579 + if (trim($line) === '') {
4580 + continue;
10277 4581 }
10278 - return strlen($header);
10279 - });
10280 4582
10281 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10282 - if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10283 - $captured_body_pre_stream .= $data;
10284 - return strlen($data);
4583 + // Claude uses event: and data: format
4584 + if (strpos($line, 'event: ') === 0) {
4585 + // Store the event type for the next data line
4586 + continue;
10285 4587 }
10286 4588
10287 - if (!$this->streaming_headers_sent) {
10288 - $this->setup_streaming_headers();
10289 - }
4589 + if (strpos($line, 'data: ') === 0) {
4590 + $json_str = substr($line, 6); // Remove 'data: ' prefix
10290 4591
10291 - if (!$stream_started && $testing_data !== null) {
10292 - echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
10293 - flush();
10294 - $stream_started = true;
10295 - }
10296 -
10297 - $buffer .= $data;
10298 - $lines = explode("\n", $buffer);
10299 - $buffer = array_pop($lines);
10300 -
10301 - foreach ($lines as $line) {
10302 - if (trim($line) === '') {
4592 + $json = json_decode($json_str, true);
4593 + if (json_last_error() !== JSON_ERROR_NONE) {
10303 4594 continue;
10304 4595 }
10305 4596
10306 - if (strpos($line, 'event: ') === 0) {
10307 - continue;
10308 - }
4597 + // Handle different event types
4598 + if (isset($json['type'])) {
4599 + switch ($json['type']) {
4600 + case 'content_block_delta':
4601 + if (isset($json['delta']['text'])) {
4602 + $content = $json['delta']['text'];
4603 + $full_response .= $content; // Accumulate
4604 + // Send as SSE format compatible with your frontend
4605 + echo "data: " . json_encode(['content' => $content]) . "\n\n";
4606 + flush();
4607 + }
4608 + break;
10309 4609
10310 - if (strpos($line, 'data: ') === 0) {
10311 - $json_str = substr($line, 6);
4610 + case 'message_stop':
4611 + echo "data: [DONE]\n\n";
4612 + flush();
4613 + break;
10312 4614
10313 - $json = json_decode(trim($json_str), true);
10314 - if (json_last_error() !== JSON_ERROR_NONE) {
10315 - continue;
4615 + case 'error':
4616 + echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown error']) . "\n\n";
4617 + flush();
4618 + break;
10316 4619 }
10317 -
10318 - if (isset($json['type'])) {
10319 - switch ($json['type']) {
10320 - case 'content_block_delta':
10321 - if (isset($json['delta']['text'])) {
10322 - $content = $json['delta']['text'];
10323 - $full_response .= $content;
10324 - echo "data: " . json_encode(['content' => $content]) . "\n\n";
10325 - flush();
10326 - }
10327 - break;
10328 -
10329 - case 'message_stop':
10330 - echo "data: [DONE]\n\n";
10331 - flush();
10332 - break;
10333 -
10334 - case 'error':
10335 - echo "data: " . json_encode(['error' => $this->extract_provider_error($json, 'Unknown error')]) . "\n\n";
10336 - flush();
10337 - break;
10338 - }
10339 - }
10340 4620 }
10341 4621 }
10342 -
10343 - return strlen($data);
10344 - });
10345 -
10346 - $response = curl_exec($ch);
10347 - $errno = curl_errno($ch);
10348 - $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10349 - curl_close($ch);
10350 -
10351 - if (!$errno && $http_code === 200) {
10352 - break;
10353 4622 }
10354 4623
10355 - $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'anthropic', $errno);
10356 - $can_retry = !$this->streaming_headers_sent
10357 - && ($attempt + 1) < $max_attempts
10358 - && $is_transient;
4624 + return strlen($data);
4625 + });
10359 4626
10360 - if (defined('WP_DEBUG') && WP_DEBUG) {
10361 - error_log(sprintf(
10362 - '[MxChat] claude_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10363 - $attempt + 1, $max_attempts, $http_code, $errno,
10364 - $is_transient ? 'yes' : 'no',
10365 - $can_retry ? 'Retrying.' : 'Giving up.'
10366 - ));
10367 - }
4627 + $response = curl_exec($ch);
4628 + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
10368 4629
10369 - if (!$can_retry) {
10370 - break;
10371 - }
4630 + if (curl_errno($ch)) {
4631 + curl_close($ch);
4632 + throw new Exception('cURL Error: ' . curl_error($ch));
10372 4633 }
10373 4634
10374 - if ($errno || $http_code !== 200) {
10375 - return $this->mxchat_stream_emit_fallback(
10376 - 'anthropic',
10377 - $this->mxchat_generate_response_claude($selected_model, $claude_api_key, array_slice($conversation_history, 0, -1), $relevant_content, $session_id),
10378 - $session_id,
10379 - $testing_data
4635 + curl_close($ch);
4636 +
4637 + if ($http_code !== 200) {
4638 + // Fallback to regular response
4639 + //error_log("MxChat: Claude streaming failed with HTTP $http_code, falling back");
4640 + $regular_response = $this->mxchat_generate_response_claude(
4641 + $selected_model,
4642 + $claude_api_key,
4643 + array_slice($conversation_history, 0, -1), // Remove the added content
4644 + $relevant_content
10380 4645 );
4646 +
4647 + $response_data = [
4648 + 'text' => $regular_response,
4649 + 'html' => '',
4650 + 'session_id' => $session_id
4651 + ];
4652 +
4653 + if ($testing_data !== null) {
4654 + $response_data['testing_data'] = $testing_data;
4655 + //error_log("MxChat Testing: Added testing data to Claude error fallback");
4656 + }
4657 +
4658 + header('Content-Type: application/json');
4659 + echo json_encode($response_data);
4660 + return true;
10381 4661 }
10382 4662
10383 4663 // Save the complete response to maintain chat persistence
10384 4664 if (!empty($full_response) && !empty($session_id)) {
10385 - // Prepare RAG context for streaming response
10386 - $rag_context_for_storage = null;
10387 - $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10388 - $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10389 -
10390 - if ($has_rag_data || $has_action_data) {
10391 - $rag_context_for_storage = [];
10392 -
10393 - if ($has_rag_data) {
10394 - $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10395 - $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10396 - $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10397 - $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10398 - $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10399 - }
10400 -
10401 - if ($has_action_data) {
10402 - $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10403 - }
10404 - }
10405 - $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
4665 + $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
10406 4666 }
10407 4667
10408 4668 return true; // Indicate streaming completed successfully
10409 4669
10410 4670 } catch (Exception $e) {
10411 - return $this->mxchat_stream_emit_fallback(
10412 - 'anthropic',
10413 - $this->mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id),
10414 - $session_id,
10415 - $testing_data
4671 + //error_log("MxChat Claude streaming exception: " . $e->getMessage());
4672 +
4673 + // Fallback to regular response on exception
4674 + $regular_response = $this->mxchat_generate_response_claude(
4675 + $selected_model,
4676 + $claude_api_key,
4677 + $conversation_history,
4678 + $relevant_content
10416 4679 );
4680 +
4681 + $response_data = [
4682 + 'text' => $regular_response,
4683 + 'html' => '',
4684 + 'session_id' => $session_id
4685 + ];
4686 +
4687 + if ($testing_data !== null) {
4688 + $response_data['testing_data'] = $testing_data;
4689 + //error_log("MxChat Testing: Added testing data to Claude exception fallback");
4690 + }
4691 +
4692 + header('Content-Type: application/json');
4693 + echo json_encode($response_data);
4694 + return true;
10417 4695 }
10418 4696 }
10419 4697 private function mxchat_generate_response_xai_stream($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10420 4698 try {
10421 - // Get bot ID from session or request
10422 - $bot_id = $this->get_current_bot_id($session_id);
4699 + // Get system prompt instructions from options
4700 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
10423 4701
10424 - // Get system prompt instructions using centralized function
10425 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10426 -
10427 4702 // Ensure conversation_history is an array
10428 4703 if (!is_array($conversation_history)) {
10429 4704 $conversation_history = array();
10430 4705 }
@@ -10460,17 +4735,11 @@
10460 4735 $regular_response = $this->mxchat_generate_response_xai(
10461 4736 $selected_model,
10462 4737 $xai_api_key,
10463 4738 $conversation_history,
10464 - $relevant_content,
10465 - $session_id
4739 + $relevant_content
10466 4740 );
10467 4741
10468 - // Save bot response to transcript
10469 - if (!empty($regular_response) && !empty($session_id)) {
10470 - $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10471 - }
10472 -
10473 4742 $response_data = [
10474 4743 'text' => $regular_response,
10475 4744 'html' => '',
10476 4745 'session_id' => $session_id
@@ -10493,179 +4762,135 @@
10493 4762 'temperature' => 0.8,
10494 4763 'stream' => true
10495 4764 ]);
10496 4765
10497 - // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10498 -
10499 - $captured_status_code = 0;
10500 - $captured_body_pre_stream = '';
10501 - $full_response = '';
4766 + // Use cURL for streaming support
4767 + $ch = curl_init();
4768 + curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
4769 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
4770 + curl_setopt($ch, CURLOPT_POST, true);
4771 + curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
4772 + curl_setopt($ch, CURLOPT_HTTPHEADER, array(
4773 + 'Content-Type: application/json',
4774 + 'Authorization: Bearer ' . $xai_api_key
4775 + ));
4776 + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
4777 + curl_setopt($ch, CURLOPT_TIMEOUT, 60);
4778 +
4779 + $full_response = ''; // Accumulate full response for saving
10502 4780 $stream_started = false;
10503 - $buffer = '';
10504 - $errno = 0;
10505 - $http_code = 0;
10506 - $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10507 - $backoff_ms = array(0, 750, 2000);
10508 -
10509 - for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10510 - if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10511 - usleep($backoff_ms[$attempt] * 1000);
4781 +
4782 + // Buffer control for real-time streaming
4783 + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, $testing_data) {
4784 + // Send testing data as the first event if available
4785 + if (!$stream_started && $testing_data !== null) {
4786 + echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
4787 + flush();
4788 + $stream_started = true;
4789 + //error_log("MxChat Testing: Sent testing data in X.AI stream");
10512 4790 }
10513 -
10514 - $captured_status_code = 0;
10515 - $captured_body_pre_stream = '';
10516 - $full_response = '';
10517 - $stream_started = false;
10518 - $buffer = '';
10519 -
10520 - $ch = curl_init();
10521 - curl_setopt($ch, CURLOPT_URL, 'https://api.x.ai/v1/chat/completions');
10522 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10523 - curl_setopt($ch, CURLOPT_POST, true);
10524 - curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10525 - curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10526 - 'Content-Type: application/json',
10527 - 'Authorization: Bearer ' . $xai_api_key
10528 - ));
10529 - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10530 - curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10531 -
10532 - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10533 - if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10534 - $captured_status_code = (int) $m[1];
4791 +
4792 + // Process each chunk of data
4793 + $lines = explode("\n", $data);
4794 +
4795 + foreach ($lines as $line) {
4796 + if (trim($line) === '' || strpos($line, 'data: ') !== 0) {
4797 + continue;
10535 4798 }
10536 - return strlen($header);
10537 - });
10538 -
10539 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10540 - if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10541 - $captured_body_pre_stream .= $data;
10542 - return strlen($data);
4799 +
4800 + $json_str = substr($line, 6); // Remove 'data: ' prefix
4801 +
4802 + if ($json_str === '[DONE]') {
4803 + echo "data: [DONE]\n\n";
4804 + flush();
4805 + continue;
10543 4806 }
10544 -
10545 - if (!$this->streaming_headers_sent) {
10546 - $this->setup_streaming_headers();
10547 - }
10548 -
10549 - if (!$stream_started && $testing_data !== null) {
10550 - echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
4807 +
4808 + $json = json_decode($json_str, true);
4809 + if (isset($json['choices'][0]['delta']['content'])) {
4810 + $content = $json['choices'][0]['delta']['content'];
4811 + $full_response .= $content; // Accumulate
4812 + // Send as SSE format
4813 + echo "data: " . json_encode(['content' => $content]) . "\n\n";
10551 4814 flush();
10552 - $stream_started = true;
10553 4815 }
10554 -
10555 - $buffer .= $data;
10556 - $lines = explode("\n", $buffer);
10557 - $buffer = array_pop($lines);
10558 -
10559 - foreach ($lines as $line) {
10560 - if (trim($line) === '') {
10561 - continue;
10562 - }
10563 - if (strpos($line, 'data: ') !== 0) {
10564 - continue;
10565 - }
10566 -
10567 - $json_str = substr($line, 6);
10568 -
10569 - if (trim($json_str) === '[DONE]') {
10570 - echo "data: [DONE]\n\n";
10571 - flush();
10572 - continue;
10573 - }
10574 -
10575 - $json = json_decode(trim($json_str), true);
10576 - if ($json && isset($json['choices'][0]['delta']['content'])) {
10577 - $content = $json['choices'][0]['delta']['content'];
10578 - $full_response .= $content;
10579 - echo "data: " . json_encode(['content' => $content]) . "\n\n";
10580 - flush();
10581 - }
10582 - }
10583 -
10584 - return strlen($data);
10585 - });
10586 -
10587 - $response = curl_exec($ch);
10588 - $errno = curl_errno($ch);
10589 - $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
4816 + }
4817 +
4818 + return strlen($data);
4819 + });
4820 +
4821 + $response = curl_exec($ch);
4822 + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
4823 +
4824 + if (curl_errno($ch) || $http_code !== 200) {
10590 4825 curl_close($ch);
10591 -
10592 - if (!$errno && $http_code === 200) {
10593 - break;
4826 +
4827 + // Fallback to regular response
4828 + //error_log("MxChat: X.AI streaming failed, falling back");
4829 + $regular_response = $this->mxchat_generate_response_xai(
4830 + $selected_model,
4831 + $xai_api_key,
4832 + $conversation_history,
4833 + $relevant_content
4834 + );
4835 +
4836 + $response_data = [
4837 + 'text' => $regular_response,
4838 + 'html' => '',
4839 + 'session_id' => $session_id
4840 + ];
4841 +
4842 + if ($testing_data !== null) {
4843 + $response_data['testing_data'] = $testing_data;
4844 + //error_log("MxChat Testing: Added testing data to X.AI error fallback");
10594 4845 }
10595 -
10596 - $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'xai', $errno);
10597 - $can_retry = !$this->streaming_headers_sent
10598 - && ($attempt + 1) < $max_attempts
10599 - && $is_transient;
10600 -
10601 - if (defined('WP_DEBUG') && WP_DEBUG) {
10602 - error_log(sprintf(
10603 - '[MxChat] xai_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10604 - $attempt + 1, $max_attempts, $http_code, $errno,
10605 - $is_transient ? 'yes' : 'no',
10606 - $can_retry ? 'Retrying.' : 'Giving up.'
10607 - ));
10608 - }
10609 -
10610 - if (!$can_retry) {
10611 - break;
10612 - }
4846 +
4847 + header('Content-Type: application/json');
4848 + echo json_encode($response_data);
4849 + return true;
10613 4850 }
10614 -
10615 - if ($errno || $http_code !== 200) {
10616 - return $this->mxchat_stream_emit_fallback(
10617 - 'xai',
10618 - $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id),
10619 - $session_id,
10620 - $testing_data
10621 - );
10622 - }
10623 -
4851 +
4852 + curl_close($ch);
4853 +
10624 4854 // Save the complete response to maintain chat persistence
10625 4855 if (!empty($full_response) && !empty($session_id)) {
10626 - // Prepare RAG context for streaming response
10627 - $rag_context_for_storage = null;
10628 - $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10629 - $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10630 -
10631 - if ($has_rag_data || $has_action_data) {
10632 - $rag_context_for_storage = [];
10633 -
10634 - if ($has_rag_data) {
10635 - $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10636 - $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10637 - $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10638 - $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10639 - $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10640 - }
10641 -
10642 - if ($has_action_data) {
10643 - $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10644 - }
10645 - }
10646 - $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
4856 + $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
10647 4857 }
10648 -
4858 +
10649 4859 return true; // Indicate streaming completed successfully
10650 -
4860 +
10651 4861 } catch (Exception $e) {
10652 - return $this->mxchat_stream_emit_fallback(
10653 - 'xai',
10654 - $this->mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content),
10655 - $session_id,
10656 - $testing_data
4862 + //error_log("MxChat X.AI streaming exception: " . $e->getMessage());
4863 +
4864 + // Fallback to regular response
4865 + $regular_response = $this->mxchat_generate_response_xai(
4866 + $selected_model,
4867 + $xai_api_key,
4868 + $conversation_history,
4869 + $relevant_content
10657 4870 );
4871 +
4872 + $response_data = [
4873 + 'text' => $regular_response,
4874 + 'html' => '',
4875 + 'session_id' => $session_id
4876 + ];
4877 +
4878 + if ($testing_data !== null) {
4879 + $response_data['testing_data'] = $testing_data;
4880 + //error_log("MxChat Testing: Added testing data to X.AI exception fallback");
4881 + }
4882 +
4883 + header('Content-Type: application/json');
4884 + echo json_encode($response_data);
4885 + return true;
10658 4886 }
10659 4887 }
10660 4888 private function mxchat_generate_response_deepseek_stream($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id, $testing_data = null) {
10661 4889 try {
10662 - // Get bot ID from session or request
10663 - $bot_id = $this->get_current_bot_id($session_id);
4890 + // Get system prompt instructions from options
4891 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
10664 4892
10665 - // Get system prompt instructions using centralized function
10666 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10667 -
10668 4893 // Ensure conversation_history is an array
10669 4894 if (!is_array($conversation_history)) {
10670 4895 $conversation_history = array();
10671 4896 }
@@ -10701,17 +4926,11 @@
10701 4926 $regular_response = $this->mxchat_generate_response_deepseek(
10702 4927 $selected_model,
10703 4928 $deepseek_api_key,
10704 4929 $conversation_history,
10705 - $relevant_content,
10706 - $session_id
4930 + $relevant_content
10707 4931 );
10708 4932
10709 - // Save bot response to transcript
10710 - if (!empty($regular_response) && !empty($session_id)) {
10711 - $this->mxchat_save_chat_message($session_id, 'bot', $regular_response);
10712 - }
10713 -
10714 4933 $response_data = [
10715 4934 'text' => $regular_response,
10716 4935 'html' => '',
10717 4936 'session_id' => $session_id
@@ -10720,9 +4939,9 @@
10720 4939 if ($testing_data !== null) {
10721 4940 $response_data['testing_data'] = $testing_data;
10722 4941 //error_log("MxChat Testing: Added testing data to DeepSeek fallback response");
10723 4942 }
10724 -
4943 +
10725 4944 header('Content-Type: application/json');
10726 4945 echo json_encode($response_data);
10727 4946 return true;
10728 4947 }
@@ -10731,432 +4950,161 @@
10731 4950 $body = json_encode([
10732 4951 'model' => $selected_model,
10733 4952 'messages' => $formatted_conversation,
10734 4953 'temperature' => 0.8,
10735 - 'stream' => true,
10736 - // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
10737 - // long silent reasoning before the first delta); the widget wants
10738 - // the legacy deepseek-chat semantics = non-thinking.
10739 - 'thinking' => ['type' => 'disabled']
4954 + 'stream' => true
10740 4955 ]);
10741 4956
10742 - // V2 retry-on-initial-connect: setup_streaming_headers is lazy-fired in WRITEFUNCTION.
10743 -
10744 - $captured_status_code = 0;
10745 - $captured_body_pre_stream = '';
10746 - $full_response = '';
4957 + // Use cURL for streaming support
4958 + $ch = curl_init();
4959 + curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
4960 + curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
4961 + curl_setopt($ch, CURLOPT_POST, true);
4962 + curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
4963 + curl_setopt($ch, CURLOPT_HTTPHEADER, array(
4964 + 'Content-Type: application/json',
4965 + 'Authorization: Bearer ' . $deepseek_api_key
4966 + ));
4967 + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
4968 + curl_setopt($ch, CURLOPT_TIMEOUT, 60);
4969 +
4970 + $full_response = ''; // Accumulate full response for saving
10747 4971 $stream_started = false;
10748 - $buffer = '';
10749 - $errno = 0;
10750 - $http_code = 0;
10751 - $max_attempts = $this->mxchat_retry_enabled() ? 3 : 1;
10752 - $backoff_ms = array(0, 750, 2000);
10753 -
10754 - for ($attempt = 0; $attempt < $max_attempts; $attempt++) {
10755 - if ($attempt > 0 && $backoff_ms[$attempt] > 0) {
10756 - usleep($backoff_ms[$attempt] * 1000);
4972 +
4973 + // Buffer control for real-time streaming
4974 + curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, $testing_data) {
4975 + // Send testing data as the first event if available
4976 + if (!$stream_started && $testing_data !== null) {
4977 + echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
4978 + flush();
4979 + $stream_started = true;
4980 + //error_log("MxChat Testing: Sent testing data in DeepSeek stream");
10757 4981 }
10758 -
10759 - $captured_status_code = 0;
10760 - $captured_body_pre_stream = '';
10761 - $full_response = '';
10762 - $stream_started = false;
10763 - $buffer = '';
10764 -
10765 - $ch = curl_init();
10766 - curl_setopt($ch, CURLOPT_URL, 'https://api.deepseek.com/v1/chat/completions');
10767 - curl_setopt($ch, CURLOPT_RETURNTRANSFER, false);
10768 - curl_setopt($ch, CURLOPT_POST, true);
10769 - curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
10770 - curl_setopt($ch, CURLOPT_HTTPHEADER, array(
10771 - 'Content-Type: application/json',
10772 - 'Authorization: Bearer ' . $deepseek_api_key
10773 - ));
10774 - curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true);
10775 - curl_setopt($ch, CURLOPT_TIMEOUT, 60);
10776 -
10777 - curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($ch, $header) use (&$captured_status_code) {
10778 - if ($captured_status_code === 0 && preg_match('#^HTTP/\S+\s+(\d+)\b#', $header, $m)) {
10779 - $captured_status_code = (int) $m[1];
4982 +
4983 + // Process each chunk of data
4984 + $lines = explode("\n", $data);
4985 +
4986 + foreach ($lines as $line) {
4987 + if (trim($line) === '' || strpos($line, 'data: ') !== 0) {
4988 + continue;
10780 4989 }
10781 - return strlen($header);
10782 - });
10783 -
10784 - curl_setopt($ch, CURLOPT_WRITEFUNCTION, function($ch, $data) use (&$full_response, &$stream_started, &$buffer, &$captured_status_code, &$captured_body_pre_stream, $testing_data) {
10785 - if ($captured_status_code !== 0 && $captured_status_code !== 200) {
10786 - $captured_body_pre_stream .= $data;
10787 - return strlen($data);
4990 +
4991 + $json_str = substr($line, 6); // Remove 'data: ' prefix
4992 +
4993 + if ($json_str === '[DONE]') {
4994 + echo "data: [DONE]\n\n";
4995 + flush();
4996 + continue;
10788 4997 }
10789 -
10790 - if (!$this->streaming_headers_sent) {
10791 - $this->setup_streaming_headers();
10792 - }
10793 -
10794 - if (!$stream_started && $testing_data !== null) {
10795 - echo "data: " . json_encode(['testing_data' => $testing_data]) . "\n\n";
4998 +
4999 + $json = json_decode($json_str, true);
5000 + if (isset($json['choices'][0]['delta']['content'])) {
5001 + $content = $json['choices'][0]['delta']['content'];
5002 + $full_response .= $content; // Accumulate
5003 + // Send as SSE format
5004 + echo "data: " . json_encode(['content' => $content]) . "\n\n";
10796 5005 flush();
10797 - $stream_started = true;
10798 5006 }
10799 -
10800 - $buffer .= $data;
10801 - $lines = explode("\n", $buffer);
10802 - $buffer = array_pop($lines);
10803 -
10804 - foreach ($lines as $line) {
10805 - if (trim($line) === '') {
10806 - continue;
10807 - }
10808 - if (strpos($line, 'data: ') !== 0) {
10809 - continue;
10810 - }
10811 -
10812 - $json_str = substr($line, 6);
10813 -
10814 - if (trim($json_str) === '[DONE]') {
10815 - echo "data: [DONE]\n\n";
10816 - flush();
10817 - continue;
10818 - }
10819 -
10820 - $json = json_decode(trim($json_str), true);
10821 - if ($json && isset($json['choices'][0]['delta']['content'])) {
10822 - $content = $json['choices'][0]['delta']['content'];
10823 - $full_response .= $content;
10824 - echo "data: " . json_encode(['content' => $content]) . "\n\n";
10825 - flush();
10826 - }
5007 + }
5008 +
5009 + return strlen($data);
5010 + });
5011 +
5012 + $response = curl_exec($ch);
5013 + $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
5014 +
5015 + if (curl_errno($ch) || $http_code !== 200) {
5016 + $curl_error = curl_error($ch);
5017 + curl_close($ch);
5018 +
5019 + // Log the specific error for debugging
5020 + //error_log("MxChat: DeepSeek streaming failed - HTTP: $http_code, cURL: $curl_error");
5021 +
5022 + // Fallback to regular response
5023 + $regular_response = $this->mxchat_generate_response_deepseek(
5024 + $selected_model,
5025 + $deepseek_api_key,
5026 + $conversation_history,
5027 + $relevant_content
5028 + );
5029 +
5030 + // Handle error response from regular function
5031 + if (is_array($regular_response) && isset($regular_response['error'])) {
5032 + if ($testing_data !== null) {
5033 + $regular_response['testing_data'] = $testing_data;
10827 5034 }
10828 -
10829 - return strlen($data);
10830 - });
10831 -
10832 - $response = curl_exec($ch);
10833 - $errno = curl_errno($ch);
10834 - $http_code = $captured_status_code !== 0 ? $captured_status_code : (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
10835 - curl_close($ch);
10836 -
10837 - if (!$errno && $http_code === 200) {
10838 - break;
5035 + header('Content-Type: application/json');
5036 + echo json_encode($regular_response);
5037 + return true;
10839 5038 }
10840 -
10841 - $is_transient = $this->mxchat_is_transient_provider_error_raw($http_code, $captured_body_pre_stream, 'openai', $errno);
10842 - $can_retry = !$this->streaming_headers_sent
10843 - && ($attempt + 1) < $max_attempts
10844 - && $is_transient;
10845 -
10846 - if (defined('WP_DEBUG') && WP_DEBUG) {
10847 - error_log(sprintf(
10848 - '[MxChat] deepseek_stream initial-connect failure (attempt=%d/%d, status=%d, errno=%d, transient=%s, %s).',
10849 - $attempt + 1, $max_attempts, $http_code, $errno,
10850 - $is_transient ? 'yes' : 'no',
10851 - $can_retry ? 'Retrying.' : 'Giving up.'
10852 - ));
5039 +
5040 + $response_data = [
5041 + 'text' => $regular_response,
5042 + 'html' => '',
5043 + 'session_id' => $session_id
5044 + ];
5045 +
5046 + if ($testing_data !== null) {
5047 + $response_data['testing_data'] = $testing_data;
5048 + //error_log("MxChat Testing: Added testing data to DeepSeek error fallback");
10853 5049 }
10854 -
10855 - if (!$can_retry) {
10856 - break;
10857 - }
5050 +
5051 + header('Content-Type: application/json');
5052 + echo json_encode($response_data);
5053 + return true;
10858 5054 }
10859 -
10860 - if ($errno || $http_code !== 200) {
10861 - return $this->mxchat_stream_emit_fallback(
10862 - 'openai',
10863 - $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id),
10864 - $session_id,
10865 - $testing_data
10866 - );
10867 - }
10868 -
5055 +
5056 + curl_close($ch);
5057 +
10869 5058 // Save the complete response to maintain chat persistence
10870 5059 if (!empty($full_response) && !empty($session_id)) {
10871 - // Prepare RAG context for streaming response
10872 - $rag_context_for_storage = null;
10873 - $has_rag_data = $this->last_similarity_analysis !== null && !empty($this->last_similarity_analysis['top_matches']);
10874 - $has_action_data = isset($this->last_action_analysis) && !empty($this->last_action_analysis);
10875 -
10876 - if ($has_rag_data || $has_action_data) {
10877 - $rag_context_for_storage = [];
10878 -
10879 - if ($has_rag_data) {
10880 - $rag_context_for_storage['top_matches'] = $this->last_similarity_analysis['top_matches'];
10881 - $rag_context_for_storage['approved_urls'] = $this->current_valid_urls ?? [];
10882 - $rag_context_for_storage['similarity_threshold'] = $this->last_similarity_analysis['threshold_used'] ?? 0.35;
10883 - $rag_context_for_storage['knowledge_base_type'] = $this->last_similarity_analysis['knowledge_base_type'] ?? 'WordPress Database';
10884 - $rag_context_for_storage['total_documents_checked'] = $this->last_similarity_analysis['total_checked'] ?? 0;
10885 - }
10886 -
10887 - if ($has_action_data) {
10888 - $rag_context_for_storage['action_analysis'] = $this->last_action_analysis;
10889 - }
10890 - }
10891 - $this->mxchat_save_chat_message($session_id, 'bot', $full_response, null, $rag_context_for_storage);
5060 + $this->mxchat_save_chat_message($session_id, 'bot', $full_response);
10892 5061 }
10893 -
5062 +
10894 5063 return true; // Indicate streaming completed successfully
10895 -
5064 +
10896 5065 } catch (Exception $e) {
10897 - return $this->mxchat_stream_emit_fallback(
10898 - 'openai',
10899 - $this->mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content),
10900 - $session_id,
10901 - $testing_data
5066 + //error_log("MxChat DeepSeek streaming exception: " . $e->getMessage());
5067 +
5068 + // Fallback to regular response
5069 + $regular_response = $this->mxchat_generate_response_deepseek(
5070 + $selected_model,
5071 + $deepseek_api_key,
5072 + $conversation_history,
5073 + $relevant_content
10902 5074 );
10903 - }
10904 -}
10905 -
10906 -
10907 -/**
10908 - * Extract a human-readable error message from a decoded provider response body.
10909 - * Providers disagree on shape: OpenAI/Anthropic/Google nest it (error.message),
10910 - * xAI returns a plain string under 'error'. Mirrors mxchat-vision's shipped
10911 - * extract_provider_error(); deliberately hint-free in core (vision's too-small
10912 - * image hint is an upload concern that doesn't apply here).
10913 - *
10914 - * @param mixed $decoded_body Decoded JSON body (array), or whatever json_decode returned.
10915 - * @param string $fallback Message to return when no provider text is found.
10916 - * @return string
10917 - */
10918 -private function extract_provider_error($decoded_body, $fallback) {
10919 - $message = '';
10920 - if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10921 - $message = $decoded_body['error']['message'];
10922 - } elseif (isset($decoded_body['error']) && is_string($decoded_body['error']) && $decoded_body['error'] !== '') {
10923 - $message = $decoded_body['error'];
10924 - }
10925 -
10926 - if ($message === '') {
10927 - return $fallback;
10928 - }
10929 -
10930 - return $message;
10931 -}
10932 -
10933 -/**
10934 - * plan-4aa8e5: a provider 200 whose body parses to no text must never reach
10935 - * the widget as a silent empty bot bubble. Standard error shape for that
10936 - * case, preferring the body's own explanation — error.message first (the
10937 - * 950731 passthrough pattern), then the Responses API's
10938 - * incomplete_details.reason (e.g. "max_output_tokens") — before the generic
10939 - * retry message.
10940 - */
10941 -private function mxchat_empty_completion_error($decoded_body, $provider_label) {
10942 - $reason = '';
10943 - if (isset($decoded_body['error']['message']) && is_string($decoded_body['error']['message']) && $decoded_body['error']['message'] !== '') {
10944 - $reason = $decoded_body['error']['message'];
10945 - } elseif (isset($decoded_body['incomplete_details']['reason']) && is_string($decoded_body['incomplete_details']['reason']) && $decoded_body['incomplete_details']['reason'] !== '') {
10946 - $reason = sprintf(__('response incomplete: %s', 'mxchat'), $decoded_body['incomplete_details']['reason']);
10947 - }
10948 -
10949 - $message = ($reason !== '')
10950 - ? sprintf(esc_html__('%1$s returned an empty response (%2$s). Please try again.', 'mxchat'), $provider_label, esc_html($reason))
10951 - : sprintf(esc_html__('%s returned an empty response. Please try again.', 'mxchat'), $provider_label);
10952 -
10953 - return [
10954 - 'error' => $message,
10955 - 'error_code' => 'empty_completion',
10956 - 'provider' => strtolower($provider_label),
10957 - ];
10958 -}
10959 -
10960 -private function mxchat_generate_response_openrouter($selected_model, $openrouter_api_key, $conversation_history, $relevant_content, $session_id = '') {
10961 - try {
10962 - if (!is_array($conversation_history)) {
10963 - $conversation_history = array();
10964 - }
10965 -
10966 - $bot_id = $this->get_current_bot_id($session_id);
10967 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
10968 5075
10969 - $formatted_conversation = array();
10970 -
10971 - $formatted_conversation[] = array(
10972 - 'role' => 'system',
10973 - 'content' => $system_prompt_instructions . " " . $relevant_content
10974 - );
10975 -
10976 - foreach ($conversation_history as $message) {
10977 - if (is_array($message) && isset($message['role']) && isset($message['content'])) {
10978 - $role = $message['role'];
10979 -
10980 - if ($role === 'bot' || $role === 'agent') {
10981 - $role = 'assistant';
10982 - }
10983 - if (!in_array($role, ['system', 'assistant', 'user'])) {
10984 - $role = 'user';
10985 - }
10986 -
10987 - $formatted_conversation[] = array(
10988 - 'role' => $role,
10989 - 'content' => $message['content']
10990 - );
5076 + // Handle error response from regular function
5077 + if (is_array($regular_response) && isset($regular_response['error'])) {
5078 + if ($testing_data !== null) {
5079 + $regular_response['testing_data'] = $testing_data;
10991 5080 }
5081 + header('Content-Type: application/json');
5082 + echo json_encode($regular_response);
5083 + return true;
10992 5084 }
10993 -
10994 - $body = json_encode([
10995 - 'model' => $selected_model,
10996 - 'messages' => $formatted_conversation,
10997 - 'temperature' => 1,
10998 - ]);
10999 -
11000 - $args = [
11001 - 'body' => $body,
11002 - 'headers' => [
11003 - 'Content-Type' => 'application/json',
11004 - 'Authorization' => 'Bearer ' . $openrouter_api_key,
11005 - 'HTTP-Referer' => home_url(),
11006 - 'X-Title' => get_bloginfo('name'),
11007 - ],
11008 - 'timeout' => 60,
11009 - 'redirection' => 5,
11010 - 'blocking' => true,
11011 - 'httpversion' => '1.0',
11012 - 'sslverify' => true,
5085 +
5086 + $response_data = [
5087 + 'text' => $regular_response,
5088 + 'html' => '',
5089 + 'session_id' => $session_id
11013 5090 ];
11014 -
11015 - $response = $this->mxchat_provider_call_with_retry('https://openrouter.ai/api/v1/chat/completions', $args, 'openai');
11016 -
11017 - if (is_wp_error($response)) {
11018 - $error_message = $response->get_error_message();
11019 - return [
11020 - 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenRouter', $selected_model),
11021 - 'error_code' => 'openrouter_connection_error',
11022 - 'provider' => 'openrouter'
11023 - ];
5091 +
5092 + if ($testing_data !== null) {
5093 + $response_data['testing_data'] = $testing_data;
5094 + //error_log("MxChat Testing: Added testing data to DeepSeek exception fallback");
11024 5095 }
11025 -
11026 - $status_code = wp_remote_retrieve_response_code($response);
11027 - if ($status_code !== 200) {
11028 - $response_body = wp_remote_retrieve_body($response);
11029 - $decoded_response = json_decode($response_body, true);
11030 -
11031 - $error_message = $this->extract_provider_error($decoded_response, 'HTTP Error ' . $status_code);
11032 -
11033 - return [
11034 - 'error' => esc_html__('OpenRouter API error: ', 'mxchat') . esc_html($error_message),
11035 - 'error_code' => 'openrouter_api_error',
11036 - 'provider' => 'openrouter',
11037 - 'status_code' => $status_code
11038 - ];
11039 - }
11040 -
11041 - $response_body = wp_remote_retrieve_body($response);
11042 - $decoded_response = json_decode($response_body, true);
11043 -
11044 - if (isset($decoded_response['choices'][0]['message']['content'])) {
11045 - $text = trim($decoded_response['choices'][0]['message']['content']);
11046 - if ($text !== '') {
11047 - return $text;
11048 - }
11049 - return $this->mxchat_empty_completion_error($decoded_response, 'OpenRouter');
11050 - } else {
11051 - return [
11052 - 'error' => esc_html__('Unexpected response format from OpenRouter.', 'mxchat'),
11053 - 'error_code' => 'openrouter_response_format_error',
11054 - 'provider' => 'openrouter'
11055 - ];
11056 - }
11057 - } catch (Exception $e) {
11058 - return [
11059 - 'error' => esc_html__('System error when processing OpenRouter request: ', 'mxchat') . esc_html($e->getMessage()),
11060 - 'error_code' => 'openrouter_exception',
11061 - 'provider' => 'openrouter'
11062 - ];
5096 +
5097 + header('Content-Type: application/json');
5098 + echo json_encode($response_data);
5099 + return true;
11063 5100 }
11064 5101 }
11065 5102
11066 -/**
11067 - * Build a chat-bubble-safe message for a non-200 provider (chat) error.
11068 - *
11069 - * Visitors must NEVER see raw API internals (model names, key/billing/quota
11070 - * text). Admins (manage_options) get an actionable hint — and, for the common
11071 - * "model not available on this key" case, a direct pointer to change the model
11072 - * (the site owner can fix it in one click). Anthropic returns model-access as a
11073 - * 4xx with a message like "Claude Fable 5 is not available. Please use Opus 4.8."
11074 - *
11075 - * Provider-agnostic by design (reusable for the xai/gemini/deepseek branches),
11076 - * but Anthropic is the confirmed, reproduced case wired up here (plan 1d3b0f).
11077 - *
11078 - * @param int $http_code HTTP status from the provider.
11079 - * @param string $error_message Raw provider error.message (may be empty).
11080 - * @param string $provider_label Human provider name, e.g. 'Anthropic'.
11081 - * @param string $model The model id the failing request used. When a
11082 - * model-access failure is detected and this is
11083 - * non-empty, a persistent admin notice is armed
11084 - * (mxchat_show_model_access_notice) so the OWNER
11085 - * learns about it even when only anonymous
11086 - * visitors hit the broken bot (plan e46b8f).
11087 - * @return string Message safe to render as a chat bubble.
11088 - */
11089 -private function mxchat_friendly_chat_error($http_code, $error_message, $provider_label = '', $model = '') {
11090 - $raw = trim((string) $error_message);
5103 +private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content) {
5104 + // Get system prompt instructions from options
5105 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
11091 5106
11092 - // Detect a model-access / availability problem the site owner can fix by
11093 - // choosing a different model. (Anthropic phrasing + the common API shapes.)
11094 - $low = strtolower($raw);
11095 - $is_model_access = (strpos($low, 'not available') !== false)
11096 - || (strpos($low, 'does not have access') !== false)
11097 - || (strpos($low, 'do not have access') !== false)
11098 - || (strpos($low, 'does not exist') !== false) // OpenAI: "model `x` does not exist or you do not have access"
11099 - || (strpos($low, 'model_not_found') !== false)
11100 - || (strpos($low, 'not_found_error') !== false)
11101 - || (strpos($low, 'model not found') !== false) // xAI
11102 - || (strpos($low, 'not found') !== false) // Gemini: "models/x is not found for API version ..."
11103 - || (strpos($low, 'permission_denied') !== false) // Gemini gated model
11104 - || (strpos($low, 'permission denied') !== false);
11105 -
11106 - // Arm the persistent admin notice (throttled: skip if the same model was
11107 - // flagged within the last hour — chat errors can fire per message).
11108 - if ($is_model_access && $model !== '') {
11109 - $existing = get_option('mxchat_model_access_notice');
11110 - $stale = !is_array($existing)
11111 - || !isset($existing['model'], $existing['time'])
11112 - || $existing['model'] !== $model
11113 - || (time() - (int) $existing['time']) > HOUR_IN_SECONDS;
11114 - if ($stale) {
11115 - update_option('mxchat_model_access_notice', array(
11116 - 'model' => (string) $model,
11117 - 'provider' => (string) $provider_label,
11118 - 'time' => time(),
11119 - ), false);
11120 - }
11121 - }
11122 -
11123 - if (current_user_can('manage_options')) {
11124 - if ($is_model_access) {
11125 - return $raw !== ''
11126 - ? sprintf(
11127 - /* translators: %s: raw provider error detail */
11128 - esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings. (Details: %s)', 'mxchat'),
11129 - $raw
11130 - )
11131 - : esc_html__('The selected AI model isn\'t available on your API key. Choose another model in MxChat → Settings.', 'mxchat');
11132 - }
11133 - return $raw !== ''
11134 - ? sprintf(
11135 - /* translators: 1: provider label, 2: raw provider error detail */
11136 - esc_html__('The AI provider (%1$s) returned an error: %2$s. Check your model and API key in MxChat → Settings.', 'mxchat'),
11137 - $provider_label !== '' ? $provider_label : esc_html__('AI', 'mxchat'),
11138 - $raw
11139 - )
11140 - : esc_html__('The AI provider returned an error. Check your model and API key in MxChat → Settings.', 'mxchat');
11141 - }
11142 -
11143 - // Visitors: friendly, generic, no internals leaked.
11144 - return esc_html__('Sorry, I\'m having trouble responding right now. Please try again in a moment.', 'mxchat');
11145 -}
11146 -
11147 -private function mxchat_generate_response_claude($selected_model, $claude_api_key, $conversation_history, $relevant_content, $session_id = '') {
11148 - // Anthropic retired claude-opus-4-20250514 / claude-sonnet-4-20250514 on 2026-06-15.
11149 - // Read-time rescue: remap a saved dead ID to the current equivalent before the API call.
11150 - if ($selected_model === 'claude-opus-4-20250514') { $selected_model = 'claude-opus-4-8'; }
11151 - elseif ($selected_model === 'claude-sonnet-4-20250514') { $selected_model = 'claude-sonnet-4-6'; }
11152 -
11153 - // Get bot ID from session or request
11154 - $bot_id = $this->get_current_bot_id($session_id);
11155 -
11156 - // Get system prompt instructions using centralized function
11157 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11158 -
11159 5107 // Clean and validate conversation history
11160 5108 foreach ($conversation_history as &$message) {
11161 5109 // Convert bot and agent roles to assistant
11162 5110 if ($message['role'] === 'bot' || $message['role'] === 'agent') {
@@ -11183,17 +5131,15 @@
11183 5131 'content' => $relevant_content
11184 5132 ];
11185 5133
11186 5134 // Build request body
11187 - $payload = [
5135 + $body = json_encode([
11188 5136 'model' => $selected_model,
11189 5137 'max_tokens' => 1000,
11190 5138 'temperature' => 0.8,
11191 5139 'messages' => $conversation_history,
11192 5140 'system' => $system_prompt_instructions
11193 - ];
11194 - if ($this->mxchat_claude_omits_temperature($selected_model)) { unset($payload['temperature']); }
11195 - $body = json_encode($payload);
5141 + ]);
11196 5142
11197 5143 // Set up API request
11198 5144 $args = [
11199 5145 'body' => $body,
@@ -11209,9 +5155,9 @@
11209 5155 'sslverify' => true,
11210 5156 ];
11211 5157
11212 5158 // Make API request
11213 - $response = $this->mxchat_provider_call_with_retry('https://api.anthropic.com/v1/messages', $args, 'anthropic');
5159 + $response = wp_remote_post('https://api.anthropic.com/v1/messages', $args);
11214 5160
11215 5161 // Check for WordPress errors
11216 5162 if (is_wp_error($response)) {
11217 5163 //error_log("Claude API request error: " . $response->get_error_message());
@@ -11225,17 +5171,13 @@
11225 5171 //error_log("Claude API HTTP error: " . $http_code . " - " . $error_body);
11226 5172
11227 5173 // Try to extract error message from response
11228 5174 $error_data = json_decode($error_body, true);
11229 - $error_message = isset($error_data['error']['message']) ?
11230 - $error_data['error']['message'] :
5175 + $error_message = isset($error_data['error']['message']) ?
5176 + $error_data['error']['message'] :
11231 5177 "HTTP error " . $http_code;
11232 -
11233 - // Surface an admin-actionable message (and a model-change pointer for the
11234 - // model-access case) without leaking raw API internals to visitors. This
11235 - // is the single chokepoint for BOTH the non-streaming and streaming Claude
11236 - // paths (the stream's non-200 fallback re-enters this method). plan 1d3b0f.
11237 - return $this->mxchat_friendly_chat_error($http_code, $error_message, 'Anthropic', $selected_model);
5178 +
5179 + return "Sorry, the API returned an error: " . $error_message;
11238 5180 }
11239 5181
11240 5182 // Parse response
11241 5183 $response_body = json_decode(wp_remote_retrieve_body($response), true);
@@ -11245,20 +5187,14 @@
11245 5187 //error_log("Claude API JSON decode error: " . json_last_error_msg());
11246 5188 return "Sorry, there was an error processing the API response.";
11247 5189 }
11248 5190
11249 - // Extract and validate response content. claude-fable-5 prepends a
11250 - // thinking block to content even with no thinking param — take the first
11251 - // TEXT block rather than content[0].
11252 - if (isset($response_body['content']) && is_array($response_body['content'])) {
11253 - foreach ($response_body['content'] as $block) {
11254 - // plan-4aa8e5: skip empty text blocks — a 200 whose only text
11255 - // block trims to '' must not render as a silent empty bubble.
11256 - if (isset($block['type'], $block['text']) && $block['type'] === 'text' && trim($block['text']) !== '') {
11257 - return trim($block['text']);
11258 - }
11259 - }
11260 - return $this->mxchat_empty_completion_error($response_body, 'Claude');
5191 + // Extract and validate response content
5192 + if (isset($response_body['content']) &&
5193 + is_array($response_body['content']) &&
5194 + !empty($response_body['content']) &&
5195 + isset($response_body['content'][0]['text'])) {
5196 + return trim($response_body['content'][0]['text']);
11261 5197 }
11262 5198
11263 5199 // Log unexpected response format
11264 5200 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
@@ -11263,13 +5199,9 @@
11263 5199 // Log unexpected response format
11264 5200 //error_log("Claude API unexpected response format: " . print_r($response_body, true));
11265 5201 return "Sorry, I received an unexpected response format from the API.";
11266 5202 }
11267 -private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content, $session_id = '') {
11268 - // OpenAI retires gpt-5.1-chat-latest / gpt-5.3-chat-latest on 2026-08-10
11269 - // (replacement gpt-5.6-sol). Read-time rescue for saved / bot-level ids
11270 - // that missed mxchat_migrate_deprecated_models() (plan e46b8f).
11271 - if ($selected_model === 'gpt-5.1-chat-latest' || $selected_model === 'gpt-5.3-chat-latest') { $selected_model = 'gpt-5.6-sol'; }
5203 +private function mxchat_generate_response_openai($selected_model, $api_key, $conversation_history, $relevant_content) {
11272 5204 try {
11273 5205 // Ensure conversation_history is an array
11274 5206 if (!is_array($conversation_history)) {
11275 5207 $conversation_history = array();
@@ -11274,16 +5206,11 @@
11274 5206 if (!is_array($conversation_history)) {
11275 5207 $conversation_history = array();
11276 5208 }
11277 5209
11278 - // Get bot ID from session or request. plan eb9c38: resolve the real bot
11279 - // from the session (was hardcoded '' → always default bot on multi-bot
11280 - // installs) and fix the undefined $session_id that fed get_system_instructions.
11281 - $bot_id = $this->get_current_bot_id($session_id);
5210 + // Get system prompt instructions from options
5211 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
11282 5212
11283 - // Get system prompt instructions using centralized function
11284 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11285 -
11286 5213 // Create a new array for the formatted conversation
11287 5214 $formatted_conversation = array();
11288 5215
11289 5216 // Add system message first
@@ -11311,25 +5238,15 @@
11311 5238 );
11312 5239 }
11313 5240 }
11314 5241
11315 - // Build request body with optimal settings for fast responses
11316 - $request_body = [
5242 + $body = json_encode([
11317 5243 'model' => $selected_model,
11318 5244 'messages' => $formatted_conversation,
11319 5245 'temperature' => 1,
11320 5246 'stream' => false
11321 - ];
5247 + ]);
11322 5248
11323 - // reasoning_effort — sourced from the core model catalog (plan-dcb71c);
11324 - // frozen inline ladder lives in mxchat_reasoning_effort_fallback().
11325 - $effort = $this->mxchat_reasoning_effort_for($selected_model, 'chat');
11326 - if ($effort !== null) {
11327 - $request_body['reasoning_effort'] = $effort;
11328 - }
11329 -
11330 - $body = json_encode($request_body);
11331 -
11332 5249 $args = [
11333 5250 'body' => $body,
11334 5251 'headers' => [
11335 5252 'Content-Type' => 'application/json',
@@ -11341,14 +5258,15 @@
11341 5258 'httpversion' => '1.0',
11342 5259 'sslverify' => true,
11343 5260 ];
11344 5261
11345 - $response = $this->mxchat_provider_call_with_retry('https://api.openai.com/v1/chat/completions', $args, 'openai');
5262 + $response = wp_remote_post('https://api.openai.com/v1/chat/completions', $args);
11346 5263
11347 5264 if (is_wp_error($response)) {
11348 5265 $error_message = $response->get_error_message();
5266 + //error_log('OpenAI API Error: ' . $error_message);
11349 5267 return [
11350 - 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'OpenAI', $selected_model),
5268 + 'error' => esc_html__('Connection error when contacting OpenAI: ', 'mxchat') . esc_html($error_message),
11351 5269 'error_code' => 'openai_connection_error',
11352 5270 'provider' => 'openai'
11353 5271 ];
11354 5272 }
@@ -11365,8 +5283,10 @@
11365 5283 $error_type = isset($decoded_response['error']['type'])
11366 5284 ? $decoded_response['error']['type']
11367 5285 : 'unknown';
11368 5286
5287 + //error_log('OpenAI API HTTP Error: ' . $status_code . ' - ' . $error_message);
5288 +
11369 5289 // Handle specific error types
11370 5290 switch ($error_type) {
11371 5291 case 'invalid_request_error':
11372 5292 if (strpos($error_message, 'API key') !== false) {
@@ -11399,13 +5319,11 @@
11399 5319 'provider' => 'openai'
11400 5320 ];
11401 5321 }
11402 5322
11403 - // Generic error fallback only — the typed cases above already produce
11404 - // clean messages. Route the raw-tail generic case through the leak-safe
11405 - // helper so visitors never see provider internals. plan 5da59a.
5323 + // Generic error fallback
11406 5324 return [
11407 - 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'OpenAI', $selected_model),
5325 + 'error' => esc_html__('OpenAI API error: ', 'mxchat') . esc_html($error_message),
11408 5326 'error_code' => 'openai_api_error',
11409 5327 'provider' => 'openai',
11410 5328 'status_code' => $status_code
11411 5329 ];
@@ -11414,14 +5332,11 @@
11414 5332 $response_body = wp_remote_retrieve_body($response);
11415 5333 $decoded_response = json_decode($response_body, true);
11416 5334
11417 5335 if (isset($decoded_response['choices'][0]['message']['content'])) {
11418 - $text = trim($decoded_response['choices'][0]['message']['content']);
11419 - if ($text !== '') {
11420 - return $text;
11421 - }
11422 - return $this->mxchat_empty_completion_error($decoded_response, 'OpenAI');
5336 + return trim($decoded_response['choices'][0]['message']['content']);
11423 5337 } else {
5338 + //error_log('OpenAI API Response Format Error: ' . print_r($decoded_response, true));
11424 5339 return [
11425 5340 'error' => esc_html__('Unexpected response format from OpenAI.', 'mxchat'),
11426 5341 'error_code' => 'openai_response_format_error',
11427 5342 'provider' => 'openai'
@@ -11427,8 +5342,9 @@
11427 5342 'provider' => 'openai'
11428 5343 ];
11429 5344 }
11430 5345 } catch (Exception $e) {
5346 + //error_log('OpenAI Exception: ' . $e->getMessage());
11431 5347 return [
11432 5348 'error' => esc_html__('System error when processing OpenAI request: ', 'mxchat') . esc_html($e->getMessage()),
11433 5349 'error_code' => 'openai_exception',
11434 5350 'provider' => 'openai'
@@ -11434,17 +5350,13 @@
11434 5350 'provider' => 'openai'
11435 5351 ];
11436 5352 }
11437 5353 }
5354 +private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content) {
5355 + try {
5356 + // Get system prompt instructions from options
5357 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
11438 5358
11439 -private function mxchat_generate_response_xai($selected_model, $xai_api_key, $conversation_history, $relevant_content, $session_id = '') {
11440 - try {
11441 - // Get bot ID from session or request
11442 - $bot_id = $this->get_current_bot_id($session_id);
11443 -
11444 - // Get system prompt instructions using centralized function
11445 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11446 -
11447 5359 // Add system prompt to relevant content
11448 5360 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11449 5361
11450 5362 // Prepend system instructions to the conversation history
@@ -11493,9 +5405,9 @@
11493 5405 'sslverify' => true,
11494 5406 ];
11495 5407
11496 5408 // Make the API request
11497 - $response = $this->mxchat_provider_call_with_retry('https://api.x.ai/v1/chat/completions', $args, 'xai');
5409 + $response = wp_remote_post('https://api.x.ai/v1/chat/completions', $args);
11498 5410
11499 5411 // Process the response
11500 5412 if (is_wp_error($response)) {
11501 5413 $error_message = $response->get_error_message();
@@ -11500,9 +5412,9 @@
11500 5412 if (is_wp_error($response)) {
11501 5413 $error_message = $response->get_error_message();
11502 5414 //error_log('X.AI API Error: ' . $error_message);
11503 5415 return [
11504 - 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'X.AI', $selected_model),
5416 + 'error' => esc_html__('Connection error when contacting X.AI: ', 'mxchat') . esc_html($error_message),
11505 5417 'error_code' => 'xai_connection_error',
11506 5418 'provider' => 'xai'
11507 5419 ];
11508 5420 }
@@ -11548,23 +5460,21 @@
11548 5460 ];
11549 5461 }
11550 5462
11551 5463 // Authentication errors
11552 - if ($status_code === 401 || $status_code === 403 ||
5464 + if ($status_code === 401 || $status_code === 403 ||
11553 5465 stripos($error_message, 'auth') !== false) {
11554 5466 return [
11555 - 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat') . ' ' . esc_html($error_message),
5467 + 'error' => esc_html__('Authentication failed with X.AI. Please check your API key.', 'mxchat'),
11556 5468 'error_code' => 'xai_auth_error',
11557 5469 'provider' => 'xai'
11558 5470 ];
11559 5471 }
11560 5472
11561 - // Model errors — keep the canned category text as a prefix, but carry the
11562 - // provider's extracted reason (e.g. "Model not found: <id>") so the owner
11563 - // sees the specific model/reason instead of only the generic category.
5473 + // Model errors
11564 5474 if (stripos($error_message, 'model') !== false) {
11565 5475 return [
11566 - 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat') . ' ' . esc_html($error_message),
5476 + 'error' => esc_html__('Invalid model specified for X.AI. Please check your model configuration.', 'mxchat'),
11567 5477 'error_code' => 'xai_invalid_model',
11568 5478 'provider' => 'xai'
11569 5479 ];
11570 5480 }
@@ -11598,14 +5508,11 @@
11598 5508 'provider' => 'xai'
11599 5509 ];
11600 5510 }
11601 5511
11602 - // Generic error fallback. Route the user-facing text through the
11603 - // leak-safe helper (admins get an actionable hint, visitors a generic
11604 - // fallback) instead of echoing raw provider internals. Preserve the
11605 - // structured contract (error_code/provider/status_code) for logging. plan 5da59a.
5512 + // Generic error fallback with the actual error message
11606 5513 return [
11607 - 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'xAI', $selected_model),
5514 + 'error' => esc_html__('X.AI API error: ', 'mxchat') . esc_html($error_message),
11608 5515 'error_code' => 'xai_api_error',
11609 5516 'provider' => 'xai',
11610 5517 'status_code' => $status_code
11611 5518 ];
@@ -11614,13 +5521,9 @@
11614 5521 $response_body = wp_remote_retrieve_body($response);
11615 5522 $decoded_response = json_decode($response_body, true);
11616 5523
11617 5524 if (isset($decoded_response['choices'][0]['message']['content'])) {
11618 - $text = trim($decoded_response['choices'][0]['message']['content']);
11619 - if ($text !== '') {
11620 - return $text;
11621 - }
11622 - return $this->mxchat_empty_completion_error($decoded_response, 'X.AI');
5525 + return trim($decoded_response['choices'][0]['message']['content']);
11623 5526 } else {
11624 5527 //error_log('X.AI API Response Format Error: ' . print_r($decoded_response, true));
11625 5528 return [
11626 5529 'error' => esc_html__('Unexpected response format from X.AI.', 'mxchat'),
@@ -11638,9 +5541,9 @@
11638 5541 }
11639 5542
11640 5543
11641 5544 }
11642 -private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content, $session_id = '') {
5545 +private function mxchat_generate_response_deepseek($selected_model, $deepseek_api_key, $conversation_history, $relevant_content) {
11643 5546 try {
11644 5547 // Ensure conversation_history is an array
11645 5548 if (!is_array($conversation_history)) {
11646 5549 $conversation_history = array();
@@ -11645,14 +5548,11 @@
11645 5548 if (!is_array($conversation_history)) {
11646 5549 $conversation_history = array();
11647 5550 }
11648 5551
11649 - // Get bot ID from session or request
11650 - $bot_id = $this->get_current_bot_id($session_id);
11651 -
11652 - // Get system prompt instructions using centralized function
11653 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11654 -
5552 + // Get system prompt instructions from options
5553 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
5554 +
11655 5555 // Create a new array for the formatted conversation
11656 5556 $formatted_conversation = array();
11657 5557
11658 5558 // Add system message first
@@ -11684,13 +5584,9 @@
11684 5584 $body = json_encode([
11685 5585 'model' => $selected_model,
11686 5586 'messages' => $formatted_conversation,
11687 5587 'temperature' => 0.8,
11688 - 'stream' => false,
11689 - // DeepSeek V4 defaults to thinking mode ON (temperature ignored,
11690 - // slow reasoning-first responses); the widget wants the legacy
11691 - // deepseek-chat semantics = non-thinking.
11692 - 'thinking' => ['type' => 'disabled']
5588 + 'stream' => false
11693 5589 ]);
11694 5590
11695 5591 $args = [
11696 5592 'body' => $body,
@@ -11704,15 +5600,15 @@
11704 5600 'httpversion' => '1.0',
11705 5601 'sslverify' => true,
11706 5602 ];
11707 5603
11708 - $response = $this->mxchat_provider_call_with_retry('https://api.deepseek.com/v1/chat/completions', $args, 'openai');
5604 + $response = wp_remote_post('https://api.deepseek.com/v1/chat/completions', $args);
11709 5605
11710 5606 if (is_wp_error($response)) {
11711 5607 $error_message = $response->get_error_message();
11712 5608 //error_log('DeepSeek API Error: ' . $error_message);
11713 5609 return [
11714 - 'error' => $this->mxchat_friendly_chat_error(0, $error_message, 'DeepSeek', $selected_model),
5610 + 'error' => esc_html__('Connection error when contacting DeepSeek: ', 'mxchat') . esc_html($error_message),
11715 5611 'error_code' => 'deepseek_connection_error',
11716 5612 'provider' => 'deepseek'
11717 5613 ];
11718 5614 }
@@ -11776,11 +5672,11 @@
11776 5672 'provider' => 'deepseek'
11777 5673 ];
11778 5674 }
11779 5675
11780 - // Generic error fallback — leak-safe helper (see plan 5da59a / 1d3b0f).
5676 + // Generic error fallback
11781 5677 return [
11782 - 'error' => $this->mxchat_friendly_chat_error($status_code, $error_message, 'DeepSeek', $selected_model),
5678 + 'error' => esc_html__('DeepSeek API error: ', 'mxchat') . esc_html($error_message),
11783 5679 'error_code' => 'deepseek_api_error',
11784 5680 'provider' => 'deepseek',
11785 5681 'status_code' => $status_code
11786 5682 ];
@@ -11789,13 +5685,9 @@
11789 5685 $response_body = wp_remote_retrieve_body($response);
11790 5686 $decoded_response = json_decode($response_body, true);
11791 5687
11792 5688 if (isset($decoded_response['choices'][0]['message']['content'])) {
11793 - $text = trim($decoded_response['choices'][0]['message']['content']);
11794 - if ($text !== '') {
11795 - return $text;
11796 - }
11797 - return $this->mxchat_empty_completion_error($decoded_response, 'DeepSeek');
5689 + return trim($decoded_response['choices'][0]['message']['content']);
11798 5690 } else {
11799 5691 //error_log('DeepSeek API Response Format Error: ' . print_r($decoded_response, true));
11800 5692 return [
11801 5693 'error' => esc_html__('Unexpected response format from DeepSeek.', 'mxchat'),
@@ -11811,20 +5703,12 @@
11811 5703 'provider' => 'deepseek'
11812 5704 ];
11813 5705 }
11814 5706 }
11815 -private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content, $session_id = '') {
11816 - // Read-time remap: gemini-3-pro-preview was shut down March 9, 2026.
11817 - // Auto-rescue existing installs whose saved model is the dead ID.
11818 - if ($selected_model === 'gemini-3-pro-preview') {
11819 - $selected_model = 'gemini-3.1-pro-preview';
11820 - }
11821 - // Get bot ID from session or request
11822 - $bot_id = $this->get_current_bot_id($session_id);
11823 -
11824 - // Get system prompt instructions using centralized function
11825 - $system_prompt_instructions = $this->get_system_instructions($bot_id, $session_id);
11826 -
5707 +private function mxchat_generate_response_gemini($selected_model, $gemini_api_key, $conversation_history, $relevant_content) {
5708 + // Get system prompt instructions from options
5709 + $system_prompt_instructions = isset($this->options['system_prompt_instructions']) ? $this->options['system_prompt_instructions'] : '';
5710 +
11827 5711 // Add system prompt to relevant content
11828 5712 $content_with_instructions = $system_prompt_instructions . " " . $relevant_content;
11829 5713
11830 5714 // Format messages for Gemini API
@@ -11889,24 +5773,10 @@
11889 5773 'parts' => $current_parts
11890 5774 ];
11891 5775 }
11892 5776
11893 - // Built-in Web Search grounding for Gemini (plan 46b9ea).
11894 - // The enable_web_search toggle historically routed ONLY to OpenAI's web_search
11895 - // tool; for a Gemini chat model it was a silent no-op. Gemini grounds natively
11896 - // (and free) via the Google Search tool, so when the toggle is on we attach it
11897 - // here on the PLAIN dispatch path. The function-calling loop (mxchat_fc_loop_gemini)
11898 - // is a SEPARATE path reached only when AI Tools are active, so grounding here
11899 - // never double-fires with function calling.
11900 - $web_search_enabled = isset($this->options['enable_web_search']) && $this->options['enable_web_search'] === 'on';
11901 - // Gemini ids that do NOT support Google Search grounding (none today — every
11902 - // shipped chat model is 2.x/3.x and grounds natively). Kept as the explicit
11903 - // opt-out list mirroring the OpenAI $unsupported_web_search_models pattern.
11904 - $gemini_unsupported_grounding = array();
11905 - $grounding_active = $web_search_enabled && !in_array($selected_model, $gemini_unsupported_grounding, true);
11906 -
11907 5777 // Build the request body
11908 - $request_payload = [
5778 + $body = json_encode([
11909 5779 'contents' => $formatted_messages,
11910 5780 'generationConfig' => [
11911 5781 'temperature' => 0.7,
11912 5782 'topP' => 0.95,
@@ -11930,30 +5800,12 @@
11930 5800 'category' => 'HARM_CATEGORY_DANGEROUS_CONTENT',
11931 5801 'threshold' => 'BLOCK_MEDIUM_AND_ABOVE'
11932 5802 ]
11933 5803 ]
11934 - ];
11935 -
11936 - if ($grounding_active) {
11937 - // Gemini 1.5 used the older google_search_retrieval shape; 2.0+ uses the
11938 - // bare google_search tool. Branch by model family so a future 1.5 id still
11939 - // grounds (no 1.5 ships today, so this resolves to google_search). The empty
11940 - // tool config must serialize as a JSON object {}, not an array [].
11941 - if (strpos($selected_model, 'gemini-1.5') !== false) {
11942 - $request_payload['tools'] = [ ['google_search_retrieval' => new \stdClass()] ];
11943 - } else {
11944 - $request_payload['tools'] = [ ['google_search' => new \stdClass()] ];
11945 - }
11946 - }
11947 -
11948 - $body = json_encode($request_payload);
11949 -
5804 + ]);
5805 +
11950 5806 // Prepare the API endpoint
11951 - // Use v1beta for preview models (Gemini 3, experimental), v1 for stable models.
11952 - // Grounding (the google_search tool) is a v1beta feature, so force v1beta whenever
11953 - // it's active — otherwise a stable model on v1 would silently drop the tool.
11954 - $api_version = ($grounding_active || strpos($selected_model, 'preview') !== false || strpos($selected_model, 'exp') !== false) ? 'v1beta' : 'v1';
11955 - $api_endpoint = 'https://generativelanguage.googleapis.com/' . $api_version . '/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
5807 + $api_endpoint = 'https://generativelanguage.googleapis.com/v1/models/' . $selected_model . ':generateContent?key=' . $gemini_api_key;
11956 5808
11957 5809 // Set up the API request
11958 5810 $args = [
11959 5811 'body' => $body,
@@ -11967,40 +5819,27 @@
11967 5819 'sslverify' => true,
11968 5820 ];
11969 5821
11970 5822 // Make the API request
11971 - $response = $this->mxchat_provider_call_with_retry($api_endpoint, $args, 'gemini');
11972 -
5823 + $response = wp_remote_post($api_endpoint, $args);
5824 +
11973 5825 // Process the response
11974 5826 if (is_wp_error($response)) {
11975 - // plan b13282: route the transport-error string through the leak-safe helper
11976 - // (admin-actionable, generic for visitors) instead of echoing the raw WP HTTP
11977 - // error. http_code 0 = no HTTP response, so the helper uses the generic branch.
11978 - return $this->mxchat_friendly_chat_error(0, $response->get_error_message(), 'Gemini', $selected_model);
5827 + return "Sorry, there was an error processing your request: " . $response->get_error_message();
11979 5828 }
11980 5829
11981 5830 $response_body = json_decode(wp_remote_retrieve_body($response), true);
11982 5831
11983 - // Handle potential errors in the response. Gemini surfaces errors as a
11984 - // 200/non-200 body with an `error` envelope; route the user-facing text
11985 - // through the leak-safe helper (admin-actionable, no visitor leak) rather
11986 - // than echoing the raw provider message. plan 5da59a.
5832 + // Handle potential errors in the response
11987 5833 if (isset($response_body['error'])) {
11988 5834 //error_log('Gemini API Error: ' . json_encode($response_body['error']));
11989 - $gemini_error_message = isset($response_body['error']['message'])
11990 - ? $response_body['error']['message']
11991 - : 'Unknown error';
11992 - $gemini_http_code = wp_remote_retrieve_response_code($response);
11993 - return $this->mxchat_friendly_chat_error($gemini_http_code, $gemini_error_message, 'Gemini', $selected_model);
5835 + return "Sorry, there was an error with the Gemini API: " .
5836 + (isset($response_body['error']['message']) ? $response_body['error']['message'] : 'Unknown error');
11994 5837 }
11995 5838
11996 5839 // Extract the response text
11997 5840 if (isset($response_body['candidates'][0]['content']['parts'][0]['text'])) {
11998 - $text = trim($response_body['candidates'][0]['content']['parts'][0]['text']);
11999 - if ($text !== '') {
12000 - return $text;
12001 - }
12002 - return $this->mxchat_empty_completion_error($response_body, 'Gemini');
5841 + return trim($response_body['candidates'][0]['content']['parts'][0]['text']);
12003 5842 } else {
12004 5843 //error_log('Unexpected Gemini API response format: ' . json_encode($response_body));
12005 5844 return "Sorry, I couldn't process that request. The response format was unexpected.";
12006 5845 }
@@ -12005,12 +5844,11 @@
12005 5844 return "Sorry, I couldn't process that request. The response format was unexpected.";
12006 5845 }
12007 5846 }
12008 5847
12009 -
12010 5848 public function test_streaming_request() {
12011 5849 $options = get_option('mxchat_options', []);
12012 - $model = $options['model'] ?? 'gpt-5.6-sol';
5850 + $model = $options['model'] ?? 'gpt-4o';
12013 5851
12014 5852 // Detect provider from model prefix
12015 5853 $provider = strtolower(explode('-', $model)[0]);
12016 5854
@@ -12094,10 +5932,9 @@
12094 5932 $response = $this->mxchat_generate_response_deepseek(
12095 5933 $selected_model,
12096 5934 $deepseek_api_key,
12097 5935 $conversation_history,
12098 - $relevant_content,
12099 - $session_id
5936 + $relevant_content
12100 5937 );
12101 5938 }
12102 5939 break;
12103 5940
@@ -12137,8 +5974,9 @@
12137 5974
12138 5975 return true;
12139 5976 }
12140 5977
5978 +
12141 5979 public function mxchat_dismiss_pre_chat_message() {
12142 5980 // Get and sanitize the user identifier
12143 5981 $user_id = $this->mxchat_get_user_identifier();
12144 5982 $user_id = sanitize_key($user_id);
@@ -12170,96 +6008,8 @@
12170 6008
12171 6009 wp_die();
12172 6010 }
12173 6011
12174 -/**
12175 - * Keyword leg for hybrid retrieval (plan-38ffa1): ranked keyword query over
12176 - * the WP-DB knowledge table. FULLTEXT when the index is available, LIKE on
12177 - * the top query terms otherwise (capability detected once and cached by
12178 - * MxChat_Utils::mxchat_hybrid_detect_capability). Respects the same bot
12179 - * scoping as the vector query ($bot_filter) and the same role-restriction
12180 - * access rules as vector candidates.
12181 - *
12182 - * @return array[] Ranked hits: [id, source_url, role_restriction, has_access]
12183 - */
12184 -private function mxchat_hybrid_keyword_search($user_query, $system_prompt_table, $bot_filter, $knowledge_manager) {
12185 - global $wpdb;
12186 -
12187 - $capability = get_option('mxchat_hybrid_keyword_capability', '');
12188 - if (!in_array($capability, array('fulltext', 'like'), true)) {
12189 - $capability = MxChat_Utils::mxchat_hybrid_detect_capability();
12190 - }
12191 -
12192 - $limit = 20;
12193 - $rows = array();
12194 -
12195 - if ($capability === 'fulltext') {
12196 - $rows = $wpdb->get_results($wpdb->prepare(
12197 - "SELECT id, source_url, role_restriction,
12198 - MATCH(article_content) AGAINST (%s IN NATURAL LANGUAGE MODE) AS kw_score
12199 - FROM {$system_prompt_table}
12200 - WHERE MATCH(article_content) AGAINST (%s IN NATURAL LANGUAGE MODE) {$bot_filter}
12201 - ORDER BY kw_score DESC, id ASC
12202 - LIMIT %d",
12203 - $user_query,
12204 - $user_query,
12205 - $limit
12206 - ));
12207 - } else {
12208 - // LIKE fallback: length-weighted term scoring. Longer, rarer tokens
12209 - // (the SKU, the error code) must outrank ubiquitous short words — an
12210 - // equal-weight score lets "the" + one common word tie with the exact
12211 - // token and the tie-break pick the wrong row (caught by the 38ffa1
12212 - // verification harness). Stopwords are dropped outright.
12213 - $stopwords = array('the', 'and', 'for', 'you', 'your', 'with', 'this', 'that', 'are', 'was', 'can', 'how', 'what', 'does', 'have', 'has', 'about', 'from', 'not', 'but', 'all', 'any', 'our', 'their');
12214 - $terms = preg_split('/[^\p{L}\p{N}_-]+/u', (string) $user_query, -1, PREG_SPLIT_NO_EMPTY);
12215 - $terms = array_filter($terms, function ($t) use ($stopwords) {
12216 - return mb_strlen($t) >= 3 && !in_array(mb_strtolower($t), $stopwords, true);
12217 - });
12218 - $terms = array_values(array_unique(array_map('mb_strtolower', $terms)));
12219 - usort($terms, function ($a, $b) {
12220 - return mb_strlen($b) <=> mb_strlen($a);
12221 - });
12222 - $terms = array_slice($terms, 0, 5);
12223 - if (empty($terms)) {
12224 - return array();
12225 - }
12226 -
12227 - $score_parts = array();
12228 - $where_parts = array();
12229 - $like_params = array();
12230 - foreach ($terms as $term) {
12231 - $score_parts[] = '((article_content LIKE %s) * ' . (int) mb_strlen($term) . ')';
12232 - $where_parts[] = 'article_content LIKE %s';
12233 - $like_params[] = '%' . $wpdb->esc_like($term) . '%';
12234 - }
12235 - $sql = "SELECT id, source_url, role_restriction, ("
12236 - . implode(' + ', $score_parts)
12237 - . ") AS kw_score FROM {$system_prompt_table} WHERE ("
12238 - . implode(' OR ', $where_parts)
12239 - . ") {$bot_filter} ORDER BY kw_score DESC, id ASC LIMIT %d";
12240 - $rows = $wpdb->get_results($wpdb->prepare(
12241 - $sql,
12242 - array_merge($like_params, $like_params, array($limit))
12243 - ));
12244 - }
12245 -
12246 - $hits = array();
12247 - foreach ((array) $rows as $row) {
12248 - $role_restriction = $row->role_restriction ?? 'public';
12249 - if (!$knowledge_manager->mxchat_user_has_content_access($role_restriction)) {
12250 - continue;
12251 - }
12252 - $hits[] = array(
12253 - 'id' => (int) $row->id,
12254 - 'source_url' => $row->source_url ?? '',
12255 - 'role_restriction' => $role_restriction,
12256 - 'has_access' => true,
12257 - );
12258 - }
12259 - return $hits;
12260 -}
12261 -
12262 6012 private function mxchat_calculate_cosine_similarity($vectorA, $vectorB) {
12263 6013 if (!is_array($vectorA) || !is_array($vectorB) || empty($vectorA) || empty($vectorB)) {
12264 6014 return 0;
12265 6015 }
@@ -12280,88 +6030,40 @@
12280 6030
12281 6031 return $dotProduct / ($normA * $normB);
12282 6032 }
12283 6033
12284 -
12285 -public function mxchat_enqueue_scripts_styles($force = false) {
12286 - // Idempotency guard (plan-915355): the smart-asset-loading safety net in
12287 - // render_chatbot_shortcode() may invoke this method a second time (or on
12288 - // every shortcode render). Run the body at most once per request so the
12289 - // nonce, dynamic-settings merge, delayed transient write, and wp_footer
12290 - // loader action never happen twice.
12291 - static $did_run = false;
12292 - if ($did_run) {
12293 - return;
12294 - }
12295 -
12296 - // Smart asset loading gate (plan-915355, opt-in, default OFF — toggle in
12297 - // MxChat → Settings → Optimization → Script Loading). When enabled and the
12298 - // shared display decision says the widget won't render on this request,
12299 - // skip all front-end assets. $force (the shortcode safety net) bypasses
12300 - // the gate because at that point the widget IS rendering. Note: bail
12301 - // WITHOUT setting $did_run, so a later forced call can still enqueue.
12302 - if (!$force
12303 - && class_exists('MxChat_Public')
12304 - && MxChat_Public::is_smart_asset_loading_enabled()
12305 - && !MxChat_Public::should_load_assets()) {
12306 - return;
12307 - }
12308 -
12309 - $did_run = true;
12310 -
12311 - // Fetch options from the database first to check loading strategy
12312 - $this->options = get_option('mxchat_options');
12313 - $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
12314 -
12315 - // Always enqueue CSS immediately
6034 +public function mxchat_enqueue_scripts_styles() {
6035 + // Define version numbers for the styles and scripts
6036 + $chat_style_version = '2.4.0';
6037 + $chat_script_version = '2.4.0';
6038 + // Enqueue the script
6039 + wp_enqueue_script(
6040 + 'mxchat-chat-js',
6041 + plugin_dir_url(__FILE__) . '../js/chat-script.js',
6042 + array('jquery'),
6043 + $chat_script_version,
6044 + true
6045 + );
6046 + // Enqueue the CSS
12316 6047 wp_enqueue_style(
12317 6048 'mxchat-chat-css',
12318 6049 plugin_dir_url(__FILE__) . '../css/chat-style.css',
12319 6050 array(),
12320 - MXCHAT_VERSION
6051 + $chat_style_version
12321 6052 );
12322 -
12323 - // Handle script loading based on strategy
12324 - if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
12325 - // Enqueue the script normally
12326 - wp_enqueue_script(
12327 - 'mxchat-chat-js',
12328 - plugin_dir_url(__FILE__) . '../js/chat-script.js',
12329 - array('jquery'),
12330 - MXCHAT_VERSION,
12331 - true
12332 - );
12333 -
12334 - // Add defer attribute if strategy is 'defer'
12335 - if ($loading_strategy === 'defer') {
12336 - wp_script_add_data('mxchat-chat-js', 'strategy', 'defer');
12337 - }
12338 - } else {
12339 - // For delay or interaction-based loading, we'll use a custom loader
12340 - // Don't enqueue the main script - we'll load it dynamically
12341 - add_action('wp_footer', array($this, 'mxchat_output_delayed_script_loader'), 99);
12342 - }
12343 -
6053 + // Fetch options from the database
6054 + $this->options = get_option('mxchat_options');
12344 6055 $prompts_options = get_option('mxchat_prompts_options', array());
12345 -
12346 - // Check if AI theme is active - if so, skip inline colors in JavaScript
12347 - $theme_options = get_option('mxchat_theme_options', array());
12348 - $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12349 - $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12350 - $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12351 -
6056 +
12352 6057 // Prepare settings for JavaScript
12353 6058 $style_settings = array(
12354 6059 'ajax_url' => admin_url('admin-ajax.php'),
12355 - // The chat-send nonce is now fetched per-request from /wp-json/mxchat/v1/nonce
12356 - // (plan-6a68c9) so it never sits in cached HTML. We still emit a nonce here
12357 - // as a one-shot fallback for the first interaction on a fresh page load
12358 - // (so the very first chat-send doesn't need to wait for a REST round-trip),
12359 - // but the widget refetches before each subsequent send.
12360 - 'nonce' => wp_create_nonce('mxchat_chat_send'),
12361 - 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
6060 + 'nonce' => wp_create_nonce('mxchat_chat_nonce'),
6061 + 'model' => isset($this->options['model']) ? $this->options['model'] : 'gpt-4o',
6062 + 'enable_streaming_toggle' => isset($this->options['enable_streaming_toggle']) ? $this->options['enable_streaming_toggle'] : 'on',
12362 6063 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12363 6064 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
6065 + 'rate_limit_message' => $this->options['rate_limit_message'] ?? 'Rate limit exceeded. Please try again later.',
12364 6066 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12365 6067 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12366 6068 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12367 6069 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
@@ -12376,8 +6078,9 @@
12376 6078 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12377 6079 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12378 6080 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12379 6081 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
6082 + 'chat_toolbar_toggle' => $this->options['chat_toolbar_toggle'] ?? 'off',
12380 6083 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12381 6084 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12382 6085 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12383 6086 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
@@ -12383,162 +6086,15 @@
12383 6086 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12384 6087 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off', // FIXED
12385 6088 'initial_email_state' => null, // Also fixed this undefined variable
12386 6089 'skip_email_check' => true,
12387 - 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12388 - 'skip_inline_colors' => $skip_inline_colors,
12389 - 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
6090 + 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1'
12390 6091 );
12391 -
12392 - // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12393 - // print/transcript, satisfaction rating) come from the shared
12394 - // dynamic-settings method so this inline payload and the first-open
12395 - // refresh endpoint can never drift (plan-32db95).
12396 - $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12397 -
12398 - // For normal/defer loading, use wp_localize_script.
12399 - // For delayed loading, nothing is localized or stored here: the delayed
12400 - // loader (mxchat_output_delayed_script_loader) rebuilds the full settings
12401 - // array inline from options and never reads any stored copy.
12402 - if ($loading_strategy === 'default' || $loading_strategy === 'defer') {
12403 - wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
12404 - } else {
12405 - // Late-render fallback (plan-915355): when the shortcode safety net
12406 - // forces this method during/after wp_footer (footer widget areas, late
12407 - // builder regions), the wp_footer:99 loader action registered above may
12408 - // already be past its slot. Emit the loader inline right now; its
12409 - // emitted-once guard prevents double output if :99 still fires.
12410 - if ($force && did_action('wp_footer')) {
12411 - $this->mxchat_output_delayed_script_loader();
12412 - }
12413 - }
6092 + // Pass the settings to the script
6093 + wp_localize_script('mxchat-chat-js', 'mxchatChat', $style_settings);
12414 6094 }
12415 6095
12416 -/**
12417 - * Output the delayed script loader for performance optimization
12418 - */
12419 -public function mxchat_output_delayed_script_loader() {
12420 - // Emitted-once guard (plan-915355): this can now be reached both via the
12421 - // wp_footer:99 action and via the late-render inline fallback in
12422 - // mxchat_enqueue_scripts_styles(). The loader must print exactly once.
12423 - static $emitted = false;
12424 - if ($emitted) {
12425 - return;
12426 - }
12427 - $emitted = true;
12428 6096
12429 - $this->options = get_option('mxchat_options');
12430 - $loading_strategy = isset($this->options['script_loading_strategy']) ? $this->options['script_loading_strategy'] : 'default';
12431 - $script_url = plugin_dir_url(__FILE__) . '../js/chat-script.js?ver=' . MXCHAT_VERSION;
12432 -
12433 - // Get the stored settings
12434 - $prompts_options = get_option('mxchat_prompts_options', array());
12435 - $theme_options = get_option('mxchat_theme_options', array());
12436 - $ai_theme_active = !empty($theme_options['active_ai_theme_css']);
12437 - $has_bot_theme_assignments = !empty($theme_options['bot_theme_assignments']);
12438 - $skip_inline_colors = $ai_theme_active || $has_bot_theme_assignments;
12439 -
12440 - $style_settings = array(
12441 - 'ajax_url' => admin_url('admin-ajax.php'),
12442 - // Per-request nonce — see plan-6a68c9; widget fetches via /wp-json/mxchat/v1/nonce
12443 - // before each send. This inline value is a one-shot fallback for the first interaction.
12444 - 'nonce' => wp_create_nonce('mxchat_chat_send'),
12445 - 'rest_url' => esc_url_raw(trailingslashit(rest_url('mxchat/v1'))),
12446 - 'contextual_awareness_toggle' => isset($this->options['contextual_awareness_toggle']) ? $this->options['contextual_awareness_toggle'] : 'off',
12447 - 'link_target_toggle' => $this->options['link_target_toggle'] ?? 'off',
12448 - 'complianz_toggle' => isset($this->options['complianz_toggle']) && $this->options['complianz_toggle'] === 'on',
12449 - 'user_message_bg_color' => $this->options['user_message_bg_color'] ?? '#fff',
12450 - 'user_message_font_color' => $this->options['user_message_font_color'] ?? '#212121',
12451 - 'bot_message_bg_color' => $this->options['bot_message_bg_color'] ?? '#212121',
12452 - 'bot_message_font_color' => $this->options['bot_message_font_color'] ?? '#fff',
12453 - 'top_bar_bg_color' => $this->options['top_bar_bg_color'] ?? '#212121',
12454 - 'send_button_font_color' => $this->options['send_button_font_color'] ?? '#212121',
12455 - 'close_button_color' => $this->options['close_button_color'] ?? '#fff',
12456 - 'chatbot_background_color' => $this->options['chatbot_background_color'] ?? '#212121',
12457 - 'chatbot_bg_color' => $this->options['chatbot_bg_color'] ?? '#fff',
12458 - 'icon_color' => $this->options['icon_color'] ?? '#fff',
12459 - 'chat_input_font_color' => $this->options['chat_input_font_color'] ?? '#212121',
12460 - 'chat_persistence_toggle' => $this->options['chat_persistence_toggle'] ?? 'off',
12461 - 'appendWidgetToBody' => $this->options['append_to_body'] ?? 'off',
12462 - 'live_agent_message_bg_color' => $this->options['live_agent_message_bg_color'] ?? '#ffffff',
12463 - 'live_agent_message_font_color' => $this->options['live_agent_message_font_color'] ?? '#333333',
12464 - 'mode_indicator_bg_color' => $this->options['mode_indicator_bg_color'] ?? '#767676',
12465 - 'mode_indicator_font_color' => $this->options['mode_indicator_font_color'] ?? '#ffffff',
12466 - 'toolbar_icon_color' => $this->options['toolbar_icon_color'] ?? '#212121',
12467 - 'use_pinecone' => $prompts_options['mxchat_use_pinecone'] ?? '0',
12468 - 'email_collection_enabled' => $this->options['enable_email_block'] ?? 'off',
12469 - 'initial_email_state' => null,
12470 - 'skip_email_check' => true,
12471 - 'pinecone_enabled' => isset($prompts_options['mxchat_use_pinecone']) && $prompts_options['mxchat_use_pinecone'] === '1',
12472 - 'skip_inline_colors' => $skip_inline_colors,
12473 - 'bot_theme_assignments' => $theme_options['bot_theme_assignments'] ?? array(),
12474 - );
12475 -
12476 - // Behavior gates + labels (model, streaming, rate-limit copy, toolbar,
12477 - // print/transcript, satisfaction rating) come from the shared
12478 - // dynamic-settings method so this inline payload and the first-open
12479 - // refresh endpoint can never drift (plan-32db95).
12480 - $style_settings = array_merge($style_settings, $this->get_dynamic_widget_settings());
12481 -
12482 - // Determine delay time based on strategy
12483 - $delay_ms = 0;
12484 - switch ($loading_strategy) {
12485 - case 'delay_1s':
12486 - $delay_ms = 1000;
12487 - break;
12488 - case 'delay_3s':
12489 - $delay_ms = 3000;
12490 - break;
12491 - case 'delay_5s':
12492 - $delay_ms = 5000;
12493 - break;
12494 - }
12495 -
12496 - ?>
12497 - <script type="text/javascript">
12498 - (function() {
12499 - var mxchatLoaded = false;
12500 - var mxchatChat = <?php echo wp_json_encode($style_settings); ?>;
12501 - window.mxchatChat = mxchatChat;
12502 -
12503 - function loadMxChatScript() {
12504 - if (mxchatLoaded) return;
12505 - mxchatLoaded = true;
12506 -
12507 - function appendChatScript() {
12508 - var script = document.createElement('script');
12509 - script.src = <?php echo wp_json_encode($script_url); ?>;
12510 - script.type = 'text/javascript';
12511 - document.body.appendChild(script);
12512 - }
12513 -
12514 - if (typeof jQuery !== 'undefined') {
12515 - appendChatScript();
12516 - } else {
12517 - var jq = document.createElement('script');
12518 - jq.src = <?php echo wp_json_encode(includes_url('js/jquery/jquery.min.js')); ?>;
12519 - jq.onload = appendChatScript;
12520 - document.body.appendChild(jq);
12521 - }
12522 - }
12523 -
12524 - <?php if ($loading_strategy === 'on_interaction'): ?>
12525 - // Load on user interaction
12526 - var events = ['scroll', 'mousemove', 'touchstart', 'keydown', 'click'];
12527 - events.forEach(function(evt) {
12528 - window.addEventListener(evt, loadMxChatScript, {once: true, passive: true});
12529 - });
12530 - // Fallback: load after 8 seconds if no interaction
12531 - setTimeout(loadMxChatScript, 8000);
12532 - <?php else: ?>
12533 - // Load after specified delay
12534 - setTimeout(loadMxChatScript, <?php echo intval($delay_ms); ?>);
12535 - <?php endif; ?>
12536 - })();
12537 - </script>
12538 - <?php
12539 -}
12540 -
12541 6097 /**
12542 6098 * Setup the cron jobs for rate limits with guard against multiple calls
12543 6099 */
12544 6100 public function setup_rate_limit_cron_jobs() {
@@ -12589,16 +6145,14 @@
12589 6145
12590 6146 // Try to schedule the event
12591 6147 $initial_time = time() + 300; // Start in 5 minutes
12592 6148 $result = wp_schedule_event($initial_time, 'hourly', 'mxchat_reset_rate_limits');
12593 -
6149 +
12594 6150 if ($result === false) {
12595 6151 //error_log('MxChat: Failed to schedule cron, using fallback system');
12596 6152 $this->setup_fallback_rate_limit_system();
12597 6153 } else {
12598 - if (defined('MXCHAT_DEV_MODE') && MXCHAT_DEV_MODE) {
12599 - error_log('MxChat: rate-limit reset cron event was missing and has been re-scheduled');
12600 - }
6154 + //error_log('MxChat: Successfully scheduled rate limit reset cron');
12601 6155 }
12602 6156
12603 6157 } catch (Exception $e) {
12604 6158 //error_log('MxChat: Cron setup exception: ' . $e->getMessage());
@@ -12644,34 +6198,22 @@
12644 6198 /**
12645 6199 * Enhanced fallback rate limit system
12646 6200 */
12647 6201 private function setup_fallback_rate_limit_system() {
12648 - // Idempotence matters here: with setup_rate_limit_cron_jobs() hooked to
12649 - // admin_init, a DISABLE_WP_CRON site reaches this on every guard pass.
12650 - // Unconditionally rewriting mxchat_next_rate_limit_check to now+3600 would
12651 - // slide the deadline forward forever and the fallback reset would never
12652 - // fire. Only initialize the deadline on a genuine transition into fallback
12653 - // mode (or if it's somehow missing).
12654 - $already_active = get_option('mxchat_use_fallback_rate_limits', false);
12655 -
12656 6202 // Set a flag to use database-based rate limit cleanup
12657 6203 update_option('mxchat_use_fallback_rate_limits', true);
12658 -
6204 +
12659 6205 // Schedule a one-time check to happen on the next plugin load
12660 - if (!$already_active || !get_option('mxchat_next_rate_limit_check', 0)) {
12661 - update_option('mxchat_next_rate_limit_check', time() + 3600);
12662 - }
12663 -
6206 + update_option('mxchat_next_rate_limit_check', time() + 3600);
6207 +
12664 6208 // Also set up a more frequent fallback check (every 4 hours)
12665 6209 update_option('mxchat_fallback_check_interval', 4 * 3600);
12666 -
6210 +
12667 6211 //error_log('MxChat: Fallback rate limit system activated');
12668 6212 }
12669 6213
12670 6214 /**
12671 6215 * Enhanced fallback check method
12672 - * NOTE: mxchat_check_fallback_rate_limits() in mxchat-basic.php is a second
12673 - * implementation of this same check — if either changes, change both.
12674 6216 */
12675 6217 public function check_fallback_rate_limits() {
12676 6218 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
12677 6219
@@ -12690,9 +6232,9 @@
12690 6232 update_option('mxchat_next_rate_limit_check', time() + $check_interval);
12691 6233 }
12692 6234 }
12693 6235 /**
12694 - * Enhanced rate limit check that includes fallback cleanup and bot-specific rate limits
6236 + * Enhanced rate limit check that includes fallback cleanup
12695 6237 */
12696 6238 public function check_rate_limit() {
12697 6239 // Check if we need to run fallback cleanup
12698 6240 $use_fallback = get_option('mxchat_use_fallback_rate_limits', false);
@@ -12702,66 +6244,11 @@
12702 6244 $this->mxchat_reset_rate_limits();
12703 6245 update_option('mxchat_next_rate_limit_check', time() + 3600); // Next hour
12704 6246 }
12705 6247
12706 - // Get bot ID from current request context
12707 - $bot_id = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
6248 + // Continue with your existing rate limit logic...
6249 + $all_options = get_option('mxchat_options', []);
12708 6250
12709 - // Get bot-specific options (includes rate limits if overridden)
12710 - $bot_options = $this->get_bot_options($bot_id);
12711 - $current_options = !empty($bot_options) ? $bot_options : $this->options;
12712 -
12713 - // Use bot-specific rate limits if available, otherwise fall back to default
12714 - $rate_limits_source = isset($current_options['rate_limits']) ? $current_options['rate_limits'] : get_option('mxchat_options', [])['rate_limits'] ?? [];
12715 -
12716 - // -------------------------------------------------------------------
12717 - // Whole-chatbot global cap (independent of role). Evaluated FIRST so
12718 - // it acts as a hard ceiling across all users + all roles. Default is
12719 - // 'unlimited' so existing installs are unchanged. Counter key drops
12720 - // both <role> and <user_id> segments — single pool per bot.
12721 - // -------------------------------------------------------------------
12722 - $global_cfg = isset($current_options['rate_limits_global']) && is_array($current_options['rate_limits_global'])
12723 - ? $current_options['rate_limits_global']
12724 - : (isset(get_option('mxchat_options', [])['rate_limits_global']) ? get_option('mxchat_options', [])['rate_limits_global'] : []);
12725 - $global_limit_raw = isset($global_cfg['limit']) ? (string) $global_cfg['limit'] : 'unlimited';
12726 - $global_timeframe = isset($global_cfg['timeframe']) ? (string) $global_cfg['timeframe'] : 'daily';
12727 - if ($global_limit_raw !== '' && $global_limit_raw !== 'unlimited' && (int) $global_limit_raw >= 1) {
12728 - $bot_id_for_global = isset($_POST['bot_id']) ? sanitize_key($_POST['bot_id']) : 'default';
12729 - $safe_bot_global = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id_for_global);
12730 - $global_option = 'mxchat_chat_limit_' . $safe_bot_global . '_global';
12731 - $global_data = get_option($global_option, ['count' => 0, 'timestamp' => time()]);
12732 - if ((int) $global_data['count'] === 0) {
12733 - $global_data['timestamp'] = time();
12734 - update_option($global_option, $global_data);
12735 - }
12736 - $now = time();
12737 - $ts = (int) $global_data['timestamp'];
12738 - $reset = false;
12739 - switch ($global_timeframe) {
12740 - case 'hourly': $reset = ($now - $ts) >= 3600; break;
12741 - case 'daily': $reset = ($now - $ts) >= 86400; break;
12742 - case 'weekly': $reset = ($now - $ts) >= 604800; break;
12743 - case 'monthly': $reset = ($now - $ts) >= 2592000; break;
12744 - }
12745 - if ($reset) {
12746 - $global_data = ['count' => 0, 'timestamp' => $now];
12747 - update_option($global_option, $global_data);
12748 - }
12749 - if ((int) $global_data['count'] >= (int) $global_limit_raw) {
12750 - $global_msg = !empty($global_cfg['message'])
12751 - ? $global_cfg['message']
12752 - : __('This chatbot has reached its message limit. Please try again later.', 'mxchat');
12753 - return [
12754 - 'error' => true,
12755 - 'message' => $this->process_rate_limit_message_html($global_msg),
12756 - ];
12757 - }
12758 - // Reserve the slot for this request. Per-role check below also increments
12759 - // its own counter — that is intentional, both ceilings apply independently.
12760 - $global_data['count']++;
12761 - update_option($global_option, $global_data);
12762 - }
12763 -
12764 6251 // Determine user role or if logged out
12765 6252 if (is_user_logged_in()) {
12766 6253 $user = wp_get_current_user();
12767 6254 $user_id = $user->ID;
@@ -12781,13 +6268,13 @@
12781 6268 $user_id = $this->get_client_ip();
12782 6269 }
12783 6270
12784 6271 // Check if rate limits are configured for this role
12785 - if (!isset($rate_limits_source[$role])) {
6272 + if (!isset($all_options['rate_limits'][$role])) {
12786 6273 return true; // No limit set for this role
12787 6274 }
12788 6275
12789 - $limit = $rate_limits_source[$role]['limit'];
6276 + $limit = $all_options['rate_limits'][$role]['limit'];
12790 6277
12791 6278 // If unlimited, return true immediately
12792 6279 if ($limit === 'unlimited') {
12793 6280 return true;
@@ -12792,16 +6279,13 @@
12792 6279 if ($limit === 'unlimited') {
12793 6280 return true;
12794 6281 }
12795 6282
12796 - // Get the option name for this user/role with safer naming (include bot_id for bot-specific limits)
6283 + // Get the option name for this user/role with safer naming
12797 6284 $safe_role = preg_replace('/[^a-zA-Z0-9_]/', '_', $role);
12798 6285 $safe_user_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $user_id);
12799 - $safe_bot_id = preg_replace('/[^a-zA-Z0-9_]/', '_', $bot_id);
6286 + $option_name = 'mxchat_chat_limit_' . $safe_role . '_' . $safe_user_id;
12800 6287
12801 - // Include bot_id in option name so each bot has separate rate limits
12802 - $option_name = 'mxchat_chat_limit_' . $safe_bot_id . '_' . $safe_role . '_' . $safe_user_id;
12803 -
12804 6288 // Get the counter data
12805 6289 $limit_data = get_option($option_name, ['count' => 0, 'timestamp' => time()]);
12806 6290
12807 6291 // If first request or counter reset needed, set the initial timestamp
@@ -12810,10 +6294,10 @@
12810 6294 update_option($option_name, $limit_data);
12811 6295 }
12812 6296
12813 6297 // Get the timeframe
12814 - $timeframe = isset($rate_limits_source[$role]['timeframe']) ?
12815 - $rate_limits_source[$role]['timeframe'] : 'daily';
6298 + $timeframe = isset($all_options['rate_limits'][$role]['timeframe']) ?
6299 + $all_options['rate_limits'][$role]['timeframe'] : 'daily';
12816 6300
12817 6301 // Check if the counter needs to be reset based on timeframe
12818 6302 $current_time = time();
12819 6303 $timestamp = $limit_data['timestamp'];
@@ -12842,10 +6326,10 @@
12842 6326
12843 6327 // Check if user has exceeded their limit
12844 6328 if ($limit_data['count'] >= intval($limit)) {
12845 6329 // Get the custom message for this role
12846 - $message = !empty($rate_limits_source[$role]['message'])
12847 - ? $rate_limits_source[$role]['message']
6330 + $message = !empty($all_options['rate_limits'][$role]['message'])
6331 + ? $all_options['rate_limits'][$role]['message']
12848 6332 : __('Rate limit exceeded. Please try again later.', 'mxchat');
12849 6333
12850 6334 // Add timeframe information to the message if placeholders exist
12851 6335 $timeframe_label = '';
@@ -13135,11 +6619,8 @@
13135 6619
13136 6620 /**
13137 6621 * AJAX handler to get system information for testing panel
13138 6622 */
13139 -/**
13140 - * AJAX handler to get system information for testing panel
13141 - */
13142 6623 public function mxchat_get_system_info() {
13143 6624 // Verify nonce for security
13144 6625 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13145 6626 wp_send_json_error(['message' => 'Invalid nonce']);
@@ -13157,24 +6638,10 @@
13157 6638 ? $this->options['system_prompt_instructions']
13158 6639 : 'No system prompt configured';
13159 6640
13160 6641 // Get selected model
13161 - $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-5.6-sol';
6642 + $selected_model = isset($this->options['model']) ? $this->options['model'] : 'gpt-4o';
13162 6643
13163 - // Check if OpenRouter is being used
13164 - $is_openrouter = ($selected_model === 'openrouter');
13165 - $openrouter_model = '';
13166 -
13167 - if ($is_openrouter) {
13168 - // Get the actual OpenRouter model that's selected
13169 - $openrouter_model = isset($this->options['openrouter_selected_model'])
13170 - ? $this->options['openrouter_selected_model']
13171 - : 'No OpenRouter model selected';
13172 -
13173 - // Update selected_model display to show both
13174 - $selected_model = 'OpenRouter: ' . $openrouter_model;
13175 - }
13176 -
13177 6644 // Get API key status (just check if they exist, don't expose the keys)
13178 6645 $api_status = [];
13179 6646 $api_status['openai'] = !empty($this->options['api_key']);
13180 6647 $api_status['claude'] = !empty($this->options['claude_api_key']);
@@ -13180,15 +6647,12 @@
13180 6647 $api_status['claude'] = !empty($this->options['claude_api_key']);
13181 6648 $api_status['gemini'] = !empty($this->options['gemini_api_key']);
13182 6649 $api_status['xai'] = !empty($this->options['xai_api_key']);
13183 6650 $api_status['deepseek'] = !empty($this->options['deepseek_api_key']);
13184 - $api_status['openrouter'] = !empty($this->options['openrouter_api_key']);
13185 6651
13186 6652 wp_send_json_success([
13187 6653 'system_prompt' => $system_prompt,
13188 6654 'selected_model' => $selected_model,
13189 - 'is_openrouter' => $is_openrouter,
13190 - 'openrouter_model' => $openrouter_model,
13191 6655 'api_status' => $api_status
13192 6656 ]);
13193 6657 }
13194 6658
@@ -13207,12 +6671,12 @@
13207 6671 wp_send_json_error(['message' => 'Unauthorized']);
13208 6672 return;
13209 6673 }
13210 6674
13211 - // Get similarity threshold from main options (default 35%)
6675 + // Get similarity threshold from main options (default 75%)
13212 6676 $similarity_threshold = isset($this->options['similarity_threshold'])
13213 6677 ? ((int) $this->options['similarity_threshold']) / 100
13214 - : 0.35;
6678 + : 0.75;
13215 6679
13216 6680 wp_send_json_success([
13217 6681 'threshold' => $similarity_threshold,
13218 6682 'threshold_percentage' => ($similarity_threshold * 100) . '%'
@@ -13227,42 +6691,24 @@
13227 6691 if (!wp_verify_nonce($_POST['nonce'], 'mxchat_test_nonce')) {
13228 6692 wp_send_json_error(['message' => 'Invalid nonce']);
13229 6693 return;
13230 6694 }
13231 -
6695 +
13232 6696 // Only allow admin users
13233 6697 if (!current_user_can('administrator')) {
13234 6698 wp_send_json_error(['message' => 'Unauthorized']);
13235 6699 return;
13236 6700 }
13237 -
13238 - // Check OpenAI Vector Store first (takes priority)
13239 - $vectorstore_options = get_option('mxchat_openai_vectorstore_options', array());
13240 - $use_vectorstore = (isset($vectorstore_options['mxchat_use_openai_vectorstore']) && $vectorstore_options['mxchat_use_openai_vectorstore'] === '1');
13241 -
13242 - if ($use_vectorstore) {
13243 - $vectorstore_ids = $vectorstore_options['mxchat_openai_vectorstore_ids'] ?? '';
13244 - $id_count = !empty($vectorstore_ids) ? count(array_filter(array_map('trim', explode(',', $vectorstore_ids)))) : 0;
13245 -
13246 - $kb_info = [
13247 - 'type' => 'OpenAI Vector Store',
13248 - 'status' => 'Active',
13249 - 'documents' => $id_count > 0 ? $id_count . ' vector store' . ($id_count > 1 ? 's' : '') . ' configured' : 'No vector stores configured'
13250 - ];
13251 -
13252 - wp_send_json_success($kb_info);
13253 - return;
13254 - }
13255 -
6701 +
13256 6702 // Check Pinecone vs WordPress
13257 6703 $addon_options = get_option('mxchat_pinecone_addon_options', array());
13258 6704 $use_pinecone = (isset($addon_options['mxchat_use_pinecone']) && $addon_options['mxchat_use_pinecone'] === '1');
13259 -
6705 +
13260 6706 $kb_info = [
13261 6707 'type' => $use_pinecone ? 'Pinecone' : 'WordPress Database',
13262 6708 'status' => 'Active'
13263 6709 ];
13264 -
6710 +
13265 6711 // Get document count
13266 6712 if ($use_pinecone) {
13267 6713 $kb_info['documents'] = 'Connected to Pinecone';
13268 6714 $kb_info['api_configured'] = !empty($addon_options['mxchat_pinecone_api_key']);
@@ -13272,9 +6718,9 @@
13272 6718 $table_name = $wpdb->prefix . 'mxchat_system_prompt_content';
13273 6719 $count = $wpdb->get_var("SELECT COUNT(*) FROM {$table_name}");
13274 6720 $kb_info['documents'] = $count ? $count . ' documents' : 'No documents';
13275 6721 }
13276 -
6722 +
13277 6723 wp_send_json_success($kb_info);
13278 6724 }
13279 6725
13280 6726 /**
@@ -13292,10 +6738,10 @@
13292 6738 wp_send_json_error(['message' => 'Unauthorized']);
13293 6739 return;
13294 6740 }
13295 6741
13296 - $old_session_id = isset($_POST['old_session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['old_session_id'])) : '';
13297 - $new_session_id = isset($_POST['new_session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['new_session_id'])) : '';
6742 + $old_session_id = isset($_POST['old_session_id']) ? sanitize_text_field($_POST['old_session_id']) : '';
6743 + $new_session_id = isset($_POST['new_session_id']) ? sanitize_text_field($_POST['new_session_id']) : '';
13298 6744
13299 6745 if (empty($old_session_id)) {
13300 6746 wp_send_json_error(['message' => 'Old session ID required']);
13301 6747 return;
@@ -13302,12 +6748,9 @@
13302 6748 }
13303 6749
13304 6750 // If no new session ID provided, generate one
13305 6751 if (empty($new_session_id)) {
13306 - // Cryptographically strong session id (plan-0c17b5). Prefix preserved
13307 - // exactly (other code pattern-matches on 'mxchat_chat_'). random_bytes
13308 - // is guaranteed on all supported PHP (7+).
13309 - $new_session_id = 'mxchat_chat_' . bin2hex(random_bytes(16));
6752 + $new_session_id = 'mxchat_chat_' . substr(md5(uniqid()), 0, 9);
13310 6753 }
13311 6754
13312 6755 // Clear ALL data associated with the old session
13313 6756 $this->clear_complete_session_data($old_session_id);
@@ -13337,19 +6780,10 @@
13337 6780 if (method_exists($this, 'clear_word_transients')) {
13338 6781 $this->clear_word_transients($session_id);
13339 6782 }
13340 6783
13341 - // Archive the session's per-conversation Slack channel before its option
13342 - // is deleted (plan 7458a7 — covers transcript-retention cleanup paths).
13343 - // Toggle-gated + shared-channel-guarded inside the helper; best-effort.
13344 - $stale_channel = get_option("mxchat_channel_{$session_id}", '');
13345 - if ($stale_channel !== '') {
13346 - $this->mxchat_maybe_archive_conversation_channel($session_id, $stale_channel);
13347 - }
13348 -
13349 6784 // Clear agent-related data
13350 6785 delete_option("mxchat_channel_{$session_id}");
13351 - delete_option("mxchat_thread_{$session_id}");
13352 6786 delete_option("mxchat_agent_name_{$session_id}");
13353 6787 delete_option("mxchat_email_{$session_id}");
13354 6788
13355 6789 // Clear any recommendation flow state
@@ -13368,13 +6802,9 @@
13368 6802 // Clear any other session-specific transients
13369 6803 delete_transient("mxchat_waiting_for_pdf_url_{$session_id}");
13370 6804 delete_transient("mxchat_include_pdf_in_context_{$session_id}");
13371 6805 delete_transient("mxchat_include_word_in_context_{$session_id}");
13372 -
13373 - // Clear form addon state (pending forms and submitted forms)
13374 - delete_option("mxchat_pending_form_{$session_id}");
13375 - delete_option("mxchat_submitted_forms_{$session_id}");
13376 -
6806 +
13377 6807 //error_log("MxChat: Cleared all data for session: {$session_id}");
13378 6808 }
13379 6809
13380 6810 /**
@@ -13409,15 +6839,15 @@
13409 6839 $testing_data = [
13410 6840 'query' => $message,
13411 6841 'timestamp' => time(),
13412 6842 'top_matches' => [],
13413 - 'action_matches' => [] // Add action matches
6843 + 'action_matches' => [] // NEW: Add action matches
13414 6844 ];
13415 6845
13416 6846 // Get similarity threshold
13417 6847 $similarity_threshold = isset($this->options['similarity_threshold'])
13418 6848 ? ((int) $this->options['similarity_threshold']) / 100
13419 - : 0.35;
6849 + : 0.75;
13420 6850
13421 6851 $testing_data['similarity_threshold'] = $similarity_threshold;
13422 6852
13423 6853 // Use the real similarity analysis if available
@@ -13432,9 +6862,9 @@
13432 6862
13433 6863 $testing_data['knowledge_base_type'] = $use_pinecone ? 'Pinecone' : 'WordPress Database';
13434 6864 }
13435 6865
13436 - // Include action analysis if available
6866 + // NEW: Include action analysis if available
13437 6867 if (isset($this->last_action_analysis) && !empty($this->last_action_analysis)) {
13438 6868 $testing_data['action_matches'] = $this->last_action_analysis;
13439 6869
13440 6870 // Clear it after capturing to avoid stale data
@@ -13445,18 +6875,18 @@
13445 6875 }
13446 6876
13447 6877
13448 6878 /**
13449 - * Track URL clicks from chatbot responses
6879 + * NEW: Track URL clicks from chatbot responses
13450 6880 */
13451 6881 public function mxchat_track_url_click() {
13452 6882 // Verify nonce for security
13453 - if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
6883 + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
13454 6884 wp_send_json_error(['message' => 'Invalid nonce']);
13455 6885 wp_die();
13456 6886 }
13457 6887
13458 - $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
6888 + $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
13459 6889 $clicked_url = isset($_POST['url']) ? esc_url_raw($_POST['url']) : '';
13460 6890 $message_context = isset($_POST['message_context']) ? sanitize_textarea_field($_POST['message_context']) : '';
13461 6891
13462 6892 if (empty($session_id) || empty($clicked_url)) {
@@ -13484,9 +6914,9 @@
13484 6914 wp_die();
13485 6915 }
13486 6916
13487 6917 /**
13488 - * Get URL click analytics for a session
6918 + * NEW: Get URL click analytics for a session
13489 6919 */
13490 6920 public function mxchat_get_url_clicks($session_id) {
13491 6921 global $wpdb;
13492 6922 $table_name = $wpdb->prefix . 'mxchat_url_clicks';
@@ -13498,18 +6928,18 @@
13498 6928
13499 6929 return $clicks;
13500 6930 }
13501 6931 /**
13502 - * Track the originating page where chat was started
6932 + * NEW: Track the originating page where chat was started
13503 6933 */
13504 6934 public function mxchat_track_originating_page() {
13505 6935 // Verify nonce
13506 - if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
6936 + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
13507 6937 wp_send_json_error(['message' => 'Invalid nonce']);
13508 6938 wp_die();
13509 6939 }
13510 6940
13511 - $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
6941 + $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
13512 6942 $page_url = isset($_POST['page_url']) ? esc_url_raw($_POST['page_url']) : '';
13513 6943 $page_title = isset($_POST['page_title']) ? sanitize_text_field($_POST['page_title']) : '';
13514 6944
13515 6945 if (empty($session_id)) {
@@ -13549,201 +6979,20 @@
13549 6979 wp_send_json_success(['message' => 'Originating page tracked']);
13550 6980 wp_die();
13551 6981 }
13552 6982
13553 -/**
13554 - * Validate and clean URLs from AI response
13555 - * Removes any URLs that aren't in the knowledge base
13556 - *
13557 - * @param string $response_text The AI-generated response
13558 - * @param array $valid_urls Array of URLs from the knowledge base
13559 - * @return string Cleaned response with invalid URLs removed/flagged
13560 - */
13561 -private function validate_and_clean_urls($response_text, $valid_urls, $session_id = null, $bot_id = null) {
13562 - /**
13563 - * Filter the list of URLs treated as valid (allowlisted) BEFORE the
13564 - * response URL sanitizer strips any link not in the list. Lets a site
13565 - * owner / developer whitelist links their custom function-calling tools
13566 - * return (e.g. session or speaker pages), which are otherwise absent from
13567 - * the RAG/system-prompt-derived list and get stripped to plain text.
13568 - *
13569 - * Purely additive: with no hook registered, apply_filters returns
13570 - * $valid_urls untouched, so there is zero behavior change for anyone who
13571 - * does not use the filter. Applied before the empty-check so a hooked
13572 - * allowlist can participate. (plan-mxchat-20260710-13a471)
13573 - *
13574 - * @param array $valid_urls URLs already known-valid (RAG + system prompt).
13575 - * @param string|null $session_id Current chat session id, if available.
13576 - * @param string|null $bot_id Current bot id, if available.
13577 - */
13578 - $valid_urls = apply_filters('mxchat_valid_urls', $valid_urls, $session_id, $bot_id);
13579 6983
13580 - // A bad mu-plugin returning a non-array (or non-string entries) must never
13581 - // fatal the response path — coerce defensively before any use.
13582 - if (!is_array($valid_urls)) {
13583 - $valid_urls = array();
13584 - }
13585 - $valid_urls = array_values(array_filter($valid_urls, static function ($u) {
13586 - return is_string($u) && $u !== '';
13587 - }));
13588 -
13589 - // If no valid URLs provided or empty response, return as-is
13590 - if (empty($valid_urls) || empty($response_text)) {
13591 - //error_log("Validation skipped - empty valid_urls or response");
13592 - return $response_text;
13593 - }
13594 -
13595 - // Extract all URLs from the AI response
13596 - // This regex matches http:// and https:// URLs
13597 - preg_match_all(
13598 - '#\bhttps?://[^\s<>"\')\]]+#i',
13599 - $response_text,
13600 - $matches
13601 - );
13602 -
13603 - // If no URLs found in response, return as-is
13604 - if (empty($matches[0])) {
13605 - //error_log("No URLs found in response");
13606 - return $response_text;
13607 - }
13608 -
13609 - $found_urls = $matches[0];
13610 - $cleaned_response = $response_text;
13611 - $removed_count = 0;
13612 -
13613 - // Normalize valid URLs for comparison (remove trailing slashes, fragments, etc.)
13614 - $normalized_valid_urls = array_map(function($url) {
13615 - // Remove trailing slash
13616 - $url = rtrim($url, '/');
13617 - // Remove URL fragments (#section)
13618 - $url = preg_replace('/#.*$/', '', $url);
13619 - // Remove trailing punctuation that might have been captured
13620 - $url = rtrim($url, '.,;:!?');
13621 - return $url;
13622 - }, $valid_urls);
13623 -
13624 - //error_log("Normalized valid URLs: " . print_r($normalized_valid_urls, true));
13625 -
13626 - foreach ($found_urls as $found_url) {
13627 - // Clean up the found URL (remove trailing punctuation that might have been captured)
13628 - $clean_found_url = rtrim($found_url, '.,;:!?)');
13629 -
13630 - // DEBUG: Log each URL being checked
13631 - //error_log("Checking found URL: " . $found_url);
13632 -
13633 - // Normalize for comparison
13634 - $normalized_found = rtrim($clean_found_url, '/');
13635 - $normalized_found = preg_replace('/#.*$/', '', $normalized_found);
13636 -
13637 - //error_log("Normalized found URL: " . $normalized_found);
13638 -
13639 - // Check if this URL exists in our valid URLs list
13640 - $is_valid = false;
13641 -
13642 - //error_log("Starting validation checks for: " . $normalized_found);
13643 -
13644 - // First, try exact match
13645 - if (in_array($normalized_found, $normalized_valid_urls)) {
13646 - $is_valid = true;
13647 - //error_log("EXACT MATCH FOUND");
13648 - } else {
13649 - //error_log("No exact match, checking variations...");
13650 - // If no exact match, check if it's a variation (with query params, etc.)
13651 - foreach ($normalized_valid_urls as $valid_url) {
13652 - //error_log(" Comparing against valid URL: " . $valid_url);
13653 -
13654 - // Check if the found URL starts with a valid URL (handles query params)
13655 - if (strpos($normalized_found, $valid_url) === 0) {
13656 - // Check what comes after the valid URL
13657 - $remainder = substr($normalized_found, strlen($valid_url));
13658 -
13659 - // Only valid if:
13660 - // 1. Exact match (remainder is empty)
13661 - // 2. Query params (starts with ?)
13662 - // 3. Fragment (starts with #)
13663 - if (empty($remainder) || $remainder[0] === '?' || $remainder[0] === '#') {
13664 - $is_valid = true;
13665 - //error_log(" MATCH: Found URL is valid variation of base URL");
13666 - break;
13667 - } else {
13668 - //error_log(" NOT A MATCH: Found URL extends path beyond valid URL (remainder: " . $remainder . ")");
13669 - }
13670 - }
13671 - // Also check the reverse (in case valid URL has query params)
13672 - if (strpos($valid_url, $normalized_found) === 0) {
13673 - $is_valid = true;
13674 - //error_log(" MATCH: Valid URL starts with found URL");
13675 - break;
13676 - }
13677 - }
13678 -
13679 - if (!$is_valid) {
13680 - //error_log("NO MATCH FOUND - URL should be removed");
13681 - }
13682 - }
13683 -
13684 - // If URL is not valid, remove it from the response
13685 - if (!$is_valid) {
13686 - // Log the removal for debugging
13687 - //error_log("MxChat: Removed hallucinated URL: " . $found_url);
13688 - //error_log("MxChat: Valid URLs were: " . implode(', ', array_slice($normalized_valid_urls, 0, 5)));
13689 -
13690 - $removed_count++;
13691 -
13692 - // Check if URL is part of a markdown link: [text](url)
13693 - $markdown_pattern = '/\[([^\]]+)\]\(' . preg_quote($found_url, '/') . '\)/';
13694 - if (preg_match($markdown_pattern, $cleaned_response)) {
13695 - //error_log("Found markdown link, removing but keeping text");
13696 - // Remove the markdown link but keep the text
13697 - $cleaned_response = preg_replace($markdown_pattern, '$1', $cleaned_response);
13698 - }
13699 - // Check if URL is part of an HTML link: <a href="url">text</a>
13700 - else if (preg_match('/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>(.*?)<\/a>/i', $cleaned_response, $link_match)) {
13701 - //error_log("Found HTML link, removing but keeping text");
13702 - // Remove the HTML link but keep the text
13703 - $link_text = $link_match[1];
13704 - $cleaned_response = preg_replace(
13705 - '/<a[^>]*href=["\']' . preg_quote($found_url, '/') . '["\'][^>]*>.*?<\/a>/i',
13706 - $link_text,
13707 - $cleaned_response
13708 - );
13709 - }
13710 - // Otherwise just remove the bare URL
13711 - else {
13712 - //error_log("Removing bare URL");
13713 - $cleaned_response = str_replace($found_url, '', $cleaned_response);
13714 - }
13715 - }
13716 - }
13717 -
13718 - // Log summary if any URLs were removed
13719 - if ($removed_count > 0) {
13720 - //error_log("MxChat: URL Validation Summary - Removed {$removed_count} hallucinated URL(s)");
13721 - } else {
13722 - //error_log("MxChat: URL Validation Summary - No URLs removed, all were valid");
13723 - }
13724 -
13725 - // Clean up any double spaces or awkward punctuation left behind
13726 - // IMPORTANT: Only collapse horizontal whitespace (spaces/tabs), preserve newlines for markdown formatting
13727 - $cleaned_response = preg_replace('/[^\S\n]+/', ' ', $cleaned_response); // Collapse spaces/tabs but NOT newlines
13728 - $cleaned_response = preg_replace('/[^\S\n]+([.,;:!?])/', '$1', $cleaned_response); // Same for punctuation cleanup
13729 -
13730 - //error_log("Final cleaned response: " . $cleaned_response);
13731 -
13732 - return trim($cleaned_response);
13733 -}
13734 -
13735 6984 /**
13736 6985 * AJAX handler to get current chat mode for a session
13737 6986 */
13738 6987 public function mxchat_get_current_chat_mode() {
13739 6988 // Verify nonce for security
13740 - if (!isset($_POST['nonce']) || !MxChat_Integrator::mxchat_verify_chat_send_nonce($_POST['nonce'])) {
6989 + if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'mxchat_chat_nonce')) {
13741 6990 wp_send_json_error(['message' => 'Invalid nonce']);
13742 6991 wp_die();
13743 6992 }
13744 6993
13745 - $session_id = isset($_POST['session_id']) ? MxChat_Utils::sanitize_session_id(wp_unslash($_POST['session_id'])) : '';
6994 + $session_id = isset($_POST['session_id']) ? sanitize_text_field($_POST['session_id']) : '';
13746 6995
13747 6996 if (empty($session_id)) {
13748 6997 wp_send_json_error(['message' => 'Session ID missing']);
13749 6998 wp_die();