PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / trunk
AI Engine – The Chatbot, AI Framework & MCP for WordPress vtrunk
3.5.7 3.5.6 3.5.5 3.5.4 3.5.3 3.5.2 3.5.1 3.5.0 3.4.9 3.4.8 3.4.7 0.2.1 1.6.91 0.2.2 1.6.92 0.2.3 1.6.93 0.2.4 1.6.94 0.2.5 1.6.95 0.2.6 1.6.96 0.2.7 1.6.97 0.2.8 1.6.98 0.2.9 1.6.99 0.3.0 1.7.0 0.3.1 1.7.1 0.3.2 1.7.2 0.3.3 1.7.3 0.3.4 1.7.4 0.3.5 1.7.5 0.3.6 1.7.6 0.4.0 1.7.7 0.4.1 1.7.8 0.4.2 1.7.9 0.4.3 1.8.0 0.4.4 1.8.1 0.4.5 1.8.2 0.4.6 1.8.3 0.4.7 1.8.4 0.4.8 1.8.5 0.4.9 1.8.6 0.5.0 1.8.7 0.5.1 1.8.8 0.5.2 1.8.9 0.5.3 1.9.0 0.5.4 1.9.1 0.5.5 1.9.2 0.5.6 1.9.3 0.5.7 1.9.4 0.5.8 1.9.5 0.5.9 1.9.6 0.6.0 1.9.7 0.6.1 1.9.8 0.6.2 1.9.81 0.6.3 1.9.82 0.6.4 1.9.83 0.6.5 1.9.84 0.6.6 1.9.85 0.6.7 1.9.86 0.6.8 1.9.87 0.6.9 1.9.88 0.7.0 1.9.89 0.7.1 1.9.90 0.7.2 1.9.91 0.7.3 1.9.92 0.7.4 1.9.93 0.7.5 1.9.94 0.7.6 1.9.95 0.7.7 1.9.96 0.7.8 1.9.97 0.7.9 1.9.98 0.8.0 1.9.99 0.8.1 2.0.0 0.8.2 2.0.1 0.8.3 2.0.2 0.8.4 2.0.3 0.8.5 2.0.4 0.8.6 2.0.5 0.8.7 2.0.6 0.8.8 2.0.7 0.8.9 2.0.8 0.9.0 2.0.9 0.9.2 2.1.0 0.9.3 2.1.1 0.9.4 2.1.2 0.9.5 2.1.3 0.9.6 2.1.4 0.9.7 2.1.5 0.9.8 2.1.6 0.9.81 2.1.7 0.9.82 2.1.8 0.9.83 2.1.9 0.9.84 2.2.0 0.9.85 2.2.1 0.9.86 2.2.2 0.9.87 2.2.3 0.9.88 2.2.4 0.9.89 2.2.5 0.9.9 2.2.51 0.9.91 2.2.52 0.9.92 2.2.53 0.9.93 2.2.54 0.9.94 2.2.56 0.9.95 2.2.57 0.9.96 2.2.6 0.9.97 2.2.60 0.9.98 2.2.61 0.9.99 2.2.62 1.0.0 2.2.63 1.0.01 2.2.70 1.0.1 2.2.80 1.0.2 2.2.81 1.0.3 2.2.90 1.0.4 2.2.91 1.0.5 2.2.92 1.0.6 2.2.93 1.0.7 2.2.94 1.0.8 2.2.95 1.0.9 2.3.0 1.1.0 2.3.1 1.1.1 2.3.2 1.1.2 2.3.3 1.1.3 2.3.4 1.1.4 2.3.5 1.1.5 2.3.6 1.1.6 2.3.7 1.1.7 2.3.8 1.1.8 2.3.9 1.1.9 2.4.0 1.2.0 2.4.1 1.2.1 2.4.2 1.2.2 2.4.3 1.2.21 2.4.4 1.2.3 2.4.5 1.2.30 2.4.6 1.3.0 2.4.7 1.3.1 2.4.8 1.3.2 2.4.9 1.3.3 2.5.0 1.3.31 2.5.1 1.3.32 2.5.2 1.3.33 2.5.3 1.3.34 2.5.4 1.3.35 2.5.5 1.3.36 2.5.6 1.3.37 2.5.7 1.3.38 2.5.8 1.3.39 2.5.9 1.3.40 2.6.0 1.3.41 2.6.1 1.3.42 2.6.2 1.3.43 2.6.3 1.3.44 2.6.5 1.3.45 2.6.6 1.3.46 2.6.7 1.3.47 2.6.8 1.3.48 2.6.9 1.3.49 2.7.0 1.3.50 2.7.1 1.3.51 2.7.2 1.3.52 2.7.3 1.3.53 2.7.4 1.3.54 2.7.5 1.3.56 2.7.6 1.3.57 2.7.7 1.3.58 2.7.8 1.3.59 2.7.9 1.3.60 2.8.0 1.3.61 2.8.1 1.3.62 2.8.2 1.3.63 2.8.3 1.3.64 2.8.4 1.3.65 2.8.5 1.3.66 2.8.6 1.3.67 2.8.7 1.3.68 2.8.8 1.3.69 2.8.9 1.3.70 2.9.0 1.3.71 2.9.1 1.3.72 2.9.2 1.3.73 2.9.3 1.3.74 2.9.4 1.3.75 2.9.5 1.3.76 2.9.6 1.3.77 2.9.7 1.3.78 2.9.8 1.3.79 2.9.9 1.3.80 3.0.0 1.3.81 3.0.1 1.3.82 3.0.2 1.3.83 3.0.3 1.3.84 3.0.4 1.3.85 3.0.5 1.3.86 3.0.6 1.3.87 3.0.7 1.3.88 3.0.8 1.3.89 3.0.9 1.3.90 3.1.0 1.3.91 3.1.1 1.3.92 3.1.2 1.3.93 3.1.3 1.3.94 3.1.4 1.3.95 3.1.5 1.3.96 3.1.6 1.3.97 3.1.7 1.3.98 3.1.8 1.3.99 3.1.9 1.4.0 3.2.0 1.4.1 3.2.1 1.4.2 3.2.2 1.4.3 3.2.3 1.4.4 3.2.4 1.4.5 3.2.5 1.4.6 3.2.6 1.4.7 3.2.7 1.4.8 3.2.8 1.4.9 3.2.9 1.5.0 3.3.0 1.5.1 3.3.1 1.5.2 3.3.2 1.5.3 3.3.3 1.5.4 3.3.4 1.5.5 3.3.5 1.5.6 3.3.6 1.5.7 3.3.7 1.5.8 3.3.8 1.5.9 3.3.9 1.6.0 3.4.0 1.6.1 3.4.1 1.6.2 3.4.2 1.6.3 3.4.3 1.6.5 3.4.4 1.6.51 3.4.5 1.6.52 3.4.6 1.6.53 1.6.54 1.6.55 1.6.56 1.6.57 1.6.58 1.6.59 1.6.60 1.6.61 1.6.62 1.6.63 1.6.64 1.6.65 1.6.66 1.6.67 1.6.68 trunk 1.6.69 0.0.1 1.6.70 0.0.2 1.6.71 0.0.3 1.6.72 0.0.4 1.6.73 0.0.5 1.6.74 0.0.6 1.6.75 0.0.7 1.6.76 0.0.8 1.6.77 0.0.9 1.6.78 0.1.0 1.6.79 0.1.1 1.6.81 0.1.2 1.6.82 0.1.3 1.6.83 0.1.4 1.6.84 0.1.5 1.6.85 0.1.6 1.6.86 0.1.7 1.6.87 0.1.8 1.6.88 0.1.9 1.6.89 0.2.0 1.6.90
ai-engine / classes / core.php
ai-engine / classes Last commit date
data 11 months ago engines 23 hours ago exceptions 11 months ago modules 23 hours ago query 1 week ago rest 1 month ago services 23 hours ago admin.php 1 month ago api.php 1 month ago core.php 6 days ago discussion.php 11 months ago event.php 11 months ago init.php 7 months ago logging.php 11 months ago reply.php 3 weeks ago rest.php 1 month ago
core.php
1807 lines
1 <?php
2
3 require_once( MWAI_PATH . '/vendor/autoload.php' );
4 require_once( MWAI_PATH . '/constants/init.php' );
5
6 define( 'MWAI_IMG_WAND', MWAI_URL . '/images/wand.png' );
7 define( 'MWAI_IMG_WAND_HTML', "<img style='height: 22px; margin-bottom: -5px; margin-right: 8px;'
8 src='" . MWAI_IMG_WAND . "' alt='AI Wand' />" );
9 define( 'MWAI_IMG_WAND_HTML_XS', "<img style='height: 16px; margin-bottom: -2px;'
10 src='" . MWAI_IMG_WAND . "' alt='AI Wand' />" );
11
12 class Meow_MWAI_Core {
13 public $admin = null;
14 public $is_rest = false;
15 public $is_cli = false;
16 public $site_url = null;
17 public $files = null;
18 public $tasks = null;
19 public $magicWand = null;
20 private $options = null;
21 private $option_name = 'mwai_options';
22 private $themes_option_name = 'mwai_themes';
23 private $chatbots_option_name = 'mwai_chatbots';
24 private $nonce = null;
25
26 public $chatbot = null;
27 public $discussions = null;
28 public $search = null;
29 public $advisor = null;
30
31 // Service instances for improved architecture
32 public $responseIdManager = null;
33 public $messageBuilder = null;
34 public $sessionService = null;
35 public $imageService = null;
36 public $usageStatsService = null;
37 public $modelEnvironmentService = null;
38
39 public function __construct() {
40 Meow_MWAI_Logging::init( 'mwai_options', 'AI Engine' );
41 $this->site_url = get_site_url();
42 $this->is_rest = MeowKit_MWAI_Helpers::is_rest();
43 $this->is_cli = defined( 'WP_CLI' );
44 $this->files = new Meow_MWAI_Modules_Files( $this );
45 $this->tasks = new Meow_MWAI_Modules_Tasks( $this );
46 $this->advisor = new Meow_MWAI_Modules_Advisor( $this );
47
48 // Load task examples in Dev Mode
49 if ( $this->get_option( 'dev_mode' ) ) {
50 new Meow_MWAI_Modules_Tasks_Examples( $this );
51 }
52
53 add_action( 'plugins_loaded', [ $this, 'init' ] );
54 add_action( 'wp_register_script', [ $this, 'register_scripts' ] );
55 add_action( 'wp_enqueue_scripts', [ $this, 'register_scripts' ] );
56 add_action( 'admin_enqueue_scripts', [ $this, 'register_scripts' ] );
57 }
58
59 #region Init & Scripts
60 public function init() {
61 global $mwai;
62
63 // Language
64 load_plugin_textdomain( MWAI_DOMAIN, false, basename( MWAI_PATH ) . '/languages' );
65
66 $this->chatbot = null;
67 $this->discussions = null;
68
69 // Initialize services here after autoloader is ready
70 $this->responseIdManager = new Meow_MWAI_Services_ResponseIdManager( $this );
71 $this->messageBuilder = new Meow_MWAI_Services_MessageBuilder( $this );
72 $this->sessionService = new Meow_MWAI_Services_Session( $this );
73 $this->imageService = new Meow_MWAI_Services_Image( $this );
74 $this->usageStatsService = new Meow_MWAI_Services_UsageStats( $this );
75 $this->modelEnvironmentService = new Meow_MWAI_Services_ModelEnvironment( $this );
76
77 // Start session early if needed for REST requests
78 if ( $this->is_rest && $this->sessionService->can_start_session() ) {
79 session_start();
80 }
81
82 new Meow_MWAI_Modules_Security( $this );
83
84 // REST API
85 if ( $this->is_rest ) {
86 new Meow_MWAI_Rest( $this );
87 }
88
89 // WP Admin
90 if ( is_admin() ) {
91 new Meow_MWAI_Admin( $this );
92 }
93
94 // GDPR Module
95 if ( $this->get_option( 'chatbot_gdpr_consent' ) ) {
96 new Meow_MWAI_Modules_GDPR( $this );
97 }
98
99 // Suggestions Module
100 if ( $this->get_option( 'module_suggestions' ) && ( is_admin() || $this->is_rest ) ) {
101 $this->magicWand = new Meow_MWAI_Modules_Wand( $this );
102 }
103
104 // Chatbots & Discussions
105 if ( $this->get_option( 'module_chatbots' ) ) {
106 $this->chatbot = new Meow_MWAI_Modules_Chatbot();
107 // Only instantiate discussions if the feature is enabled
108 if ( $this->get_option( 'chatbot_discussions' ) ) {
109 $this->discussions = new Meow_MWAI_Modules_Discussions();
110 }
111 }
112
113 // Search
114 if ( $this->get_option( 'module_search' ) ) {
115 $this->search = new Meow_MWAI_Modules_Search( $this );
116 }
117
118 // Forms Manager (standalone Forms UI + shortcode renderer)
119 if ( $this->get_option( 'module_forms' ) ) {
120 new Meow_MWAI_Modules_Forms_Manager( $this );
121 }
122
123 // Advanced Core
124 if ( class_exists( 'MeowPro_MWAI_Core' ) ) {
125 new MeowPro_MWAI_Core( $this );
126 }
127
128 // Editor Assistant: Pro extends with tools and feedback; free version only provides recommendations.
129 // Must be after MeowPro_MWAI_Core so the Pro class is already instantiated.
130 if ( $this->get_option( 'module_assistant', true ) && ( is_admin() || $this->is_rest ) ) {
131 if ( !class_exists( 'MeowPro_MWAI_EditorAssistant', false ) ) {
132 new Meow_MWAI_Modules_Editor_Assistant( $this );
133 }
134 }
135
136 // Simple API
137 $mwai = new Meow_MWAI_API( $this->chatbot, $this->discussions ?? null );
138
139 // MCP
140 if ( $this->get_option( 'module_mcp' ) ) {
141 new Meow_MWAI_Labs_MCP( $this );
142
143 // Core - Core WordPress MCP tools
144 if ( $this->get_option( 'mcp_core' ) ) {
145 new Meow_MWAI_Labs_MCP_Core( $this );
146 }
147
148 // Dynamic REST - WordPress REST API MCP tools
149 if ( $this->get_option( 'mcp_dynamic_rest' ) ) {
150 require_once MWAI_PATH . '/labs/mcp-rest.php';
151 new Meow_MWAI_Labs_MCP_Rest();
152 }
153
154 // Themes - Pro theme management MCP tools
155 if ( $this->get_option( 'mcp_themes' ) && class_exists( 'MeowPro_MWAI_MCP_Theme' ) ) {
156 new MeowPro_MWAI_MCP_Theme( $this );
157 }
158
159 // Plugins - Pro plugin management MCP tools
160 if ( $this->get_option( 'mcp_plugins' ) && class_exists( 'MeowPro_MWAI_MCP_Plugin' ) ) {
161 new MeowPro_MWAI_MCP_Plugin( $this );
162 }
163
164 // Database - Pro database query MCP tools
165 if ( $this->get_option( 'mcp_database' ) && class_exists( 'MeowPro_MWAI_MCP_Database' ) ) {
166 new MeowPro_MWAI_MCP_Database( $this );
167 }
168
169 // Polylang - Pro multilingual MCP tools (only if Polylang is active)
170 if ( $this->get_option( 'mcp_polylang' ) && class_exists( 'MeowPro_MWAI_MCP_Polylang' ) && function_exists( 'pll_get_post_language' ) ) {
171 new MeowPro_MWAI_MCP_Polylang( $this );
172 }
173
174 // WooCommerce - Pro WooCommerce store management MCP tools (only if WooCommerce is active)
175 if ( $this->get_option( 'mcp_woocommerce' ) && class_exists( 'MeowPro_MWAI_MCP_WooCommerce' ) && class_exists( 'WooCommerce' ) ) {
176 new MeowPro_MWAI_MCP_WooCommerce( $this );
177 }
178 }
179
180 // WP 7 Connectors integration (self-gates on WP 7+).
181 if ( class_exists( 'WP_Connector_Registry' ) ) {
182 new Meow_MWAI_Labs_WPAI_Connectors( $this );
183 }
184
185 // WP 7 AiClient gateway: register AI Engine as a provider in the
186 // AiClient registry so the WP AI framework can dispatch through us.
187 if ( class_exists( '\\WordPress\\AiClient\\AiClient' ) ) {
188 new Meow_MWAI_Labs_WPAI_Gateway( $this );
189 }
190
191 }
192
193 public function register_scripts() {
194 // Register Highlight.js
195 wp_register_script( 'mwai_highlight', MWAI_URL . 'vendor/highlightjs/highlight.min.js', [], '11.7', false );
196 // Register CSS for the themes
197 $themes = $this->get_themes();
198 foreach ( $themes as $theme ) {
199 if ( $theme['type'] === 'internal' ) {
200 $themeId = $theme['themeId'];
201 $filename = $themeId . '.css';
202 $physical_file = trailingslashit( MWAI_PATH ) . 'themes/' . $filename;
203 $cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION;
204 wp_register_style( 'mwai_chatbot_theme_' . $themeId, trailingslashit( MWAI_URL )
205 . 'themes/' . $filename, [], $cache_buster );
206 }
207 }
208 }
209
210 public function enqueue_theme( $themeId ) {
211 if ( empty( $themeId ) ) {
212 return;
213 }
214 wp_enqueue_style( "mwai_chatbot_theme_$themeId" );
215 }
216
217 public function enqueue_themes() {
218 $themes = $this->get_themes();
219 foreach ( $themes as $theme ) {
220 if ( $theme['type'] === 'internal' ) {
221 $this->enqueue_theme( $theme['themeId'] );
222 }
223 }
224 }
225
226 #endregion
227
228 #region Roles & Capabilities
229 public function can_start_session() {
230 return $this->sessionService->can_start_session();
231 }
232
233 public function can_access_settings() {
234 return apply_filters( 'mwai_allow_setup', current_user_can( 'manage_options' ) );
235 }
236
237 public function can_access_features() {
238 $editor_or_admin = current_user_can( 'editor' ) || current_user_can( 'administrator' );
239 return apply_filters( 'mwai_allow_usage', $editor_or_admin );
240 }
241
242 public function can_access_public_api( $feature, $extra ) {
243 $logged_in = is_user_logged_in();
244 return apply_filters( 'mwai_allow_public_api', $logged_in, $feature, $extra );
245 }
246 #endregion
247
248 #region AI-Related Helpers
249 public function run_query( $query, $streamCallback = null, $markdown = false ) {
250
251 // Allow to modify the query before it is sent.
252 // Different query types have specific filters for type-safe modifications.
253 if ( $query instanceof Meow_MWAI_Query_Embed ) {
254 $query = apply_filters( 'mwai_ai_embeddings_query', $query );
255 }
256 else if ( $query instanceof Meow_MWAI_Query_Feedback ) {
257 $query = apply_filters( 'mwai_ai_feedback_query', $query );
258 }
259 else {
260 $query = apply_filters( 'mwai_ai_query', $query );
261 }
262
263 // Ensure the query is still valid after filtering
264 if ( !$query || !is_object( $query ) ) {
265 throw new Exception( __( 'Invalid query object after filtering. The mwai_ai_query filter must return a valid query object.', 'ai-engine' ) );
266 }
267
268 // Validate that embeddings queries have a non-empty message
269 if ( $query instanceof Meow_MWAI_Query_Embed && empty( $query->get_message() ) ) {
270 throw new Exception( __( 'Embeddings query cannot have an empty message. Please check that the conversation context is properly extracted.', 'ai-engine' ) );
271 }
272
273 // Let's check the default environment and model.
274 $this->validate_env_model( $query );
275
276 // Create the engine based on the query's environment
277 $engine = Meow_MWAI_Engines_Factory::get( $this, $query->envId );
278
279 // Let's run the query.
280 $reply = $engine->run( $query, $streamCallback );
281
282 // Let's allow to modify the reply before it is sent.
283 if ( $markdown ) {
284 if ( $query instanceof Meow_MWAI_Query_Image || $query instanceof Meow_MWAI_Query_EditImage ) {
285 $reply->result = '';
286 foreach ( $reply->results as $result ) {
287 $reply->result .= "![Image]($result)\n";
288 }
289 }
290 }
291
292 return $reply;
293 }
294
295 public function validate_env_model( $query ) {
296 return $this->modelEnvironmentService->validate_env_model( $query );
297 }
298
299 #endregion
300
301 #region Text-Related Helpers
302
303 // Clean the text perfectly, resolve shortcodes, etc, etc.
304 public function clean_text( $rawText = '' ) {
305 $text = html_entity_decode( $rawText );
306 $text = wp_strip_all_tags( $text );
307 $text = preg_replace( '/[\r\n]+/', "\n", $text );
308 $text = preg_replace( '/\n+/', "\n", $text );
309 $text = preg_replace( '/\t+/', "\t", $text );
310 return $text . ' ';
311 }
312
313 // Make sure there are no duplicate sentences, and keep the length under a maximum length.
314 public function clean_sentences( $text, $maxLength = null ) {
315 // Step 1: Identify URLs and replace them with a placeholder.
316 $urlPattern = '/\bhttps?:\/\/[^\s()<>]+(?:\([\w\d]+\)|([^[:punct:]\s]|\/))/';
317 preg_match_all( $urlPattern, $text, $urls );
318 $urlPlaceholders = [];
319 foreach ( $urls[0] as $index => $url ) {
320 $placeholder = '{urlPlaceholder' . $index . '}';
321 $text = str_replace( $url, $placeholder, $text );
322 $urlPlaceholders[$placeholder] = $url;
323 }
324
325 $maxLength = (int) ( $maxLength ? $maxLength : $this->get_option( 'context_max_length', 4096 ) );
326 $sentences = preg_split( '/(?<=[.?!。.!?])\s+/u', $text, -1, PREG_SPLIT_NO_EMPTY );
327 $hashes = [];
328 $uniqueSentences = [];
329 $total = 0;
330
331 foreach ( $sentences as $sentence ) {
332 $sentence = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $sentence );
333 $hash = md5( $sentence );
334 if ( !in_array( $hash, $hashes ) ) {
335 $length = mb_strlen( $sentence, 'UTF-8' );
336 if ( $total + $length > $maxLength ) {
337 continue;
338 }
339 $hashes[] = $hash;
340 $uniqueSentences[] = $sentence;
341 $total += $length;
342 }
343 }
344
345 $freshText = implode( ' ', $uniqueSentences );
346
347 // Step 3: Restore URLs in the final text.
348 foreach ( $urlPlaceholders as $placeholder => $url ) {
349 $freshText = str_replace( $placeholder, $url, $freshText );
350 }
351
352 $freshText = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $freshText );
353 return $freshText;
354 }
355
356 public function get_post_content( $postId ) {
357 // Ensure we get fresh post data by clearing cache
358 clean_post_cache( $postId );
359 $post = get_post( $postId );
360 if ( !$post ) {
361 return false;
362 }
363 $text = apply_filters( 'mwai_pre_post_content', $post->post_content, $postId );
364 $pattern = '/\[mwai_.*?\]/';
365 $text = preg_replace( $pattern, '', $text );
366 if ( $this->get_option( 'resolve_shortcodes' ) ) {
367 $text = apply_filters( 'the_content', $text );
368 }
369 else {
370 $pattern = "/\[[^\]]+\]/";
371 $text = preg_replace( $pattern, '', $text );
372 $pattern = "/<!--\s*\/?wp:[^\>]+-->/";
373 $text = preg_replace( $pattern, '', $text );
374 }
375 $text = $this->clean_text( $text );
376 $text = $this->clean_sentences( $text );
377 $text = apply_filters( 'mwai_post_content', $text, $postId );
378 return $text;
379 }
380
381 public function markdown_to_html( $content ) {
382 $Parsedown = new Parsedown();
383 $content = $Parsedown->text( $content );
384 return $content;
385 }
386
387 public function get_post_language( $postId ) {
388 $locale = get_locale();
389 $code = strtolower( substr( $locale, 0, 2 ) );
390 $humanLanguage = strtr( $code, MWAI_ALL_LANGUAGES );
391 $lang = apply_filters( 'wpml_post_language_details', null, $postId );
392 if ( !empty( $lang ) ) {
393 $locale = $lang['locale'];
394 $humanLanguage = $lang['display_name'];
395 }
396 return strtolower( "$locale ($humanLanguage)" );
397 }
398
399 public function do_placeholders( $text ) {
400 $defaultPlaceholders = [
401 // Date and time in a clear, AI-friendly format (e.g., "December 11, 2025 at 3:45 PM")
402 'DATE_TIME' => date_i18n( 'F j, Y \a\t g:i A' ),
403 ];
404 $dataPlaceholders = $this->get_user_data();
405 if ( !empty( $dataPlaceholders ) ) {
406 $defaultPlaceholders = array_merge( $defaultPlaceholders, $dataPlaceholders );
407 }
408 $placeholders = apply_filters( 'mwai_placeholders', $defaultPlaceholders );
409 foreach ( $placeholders as $key => $value ) {
410 $text = str_replace( '{' . $key . '}', $value ?? '', $text );
411 }
412 // Replace any remaining unmatched placeholders with a clear indicator
413 $text = preg_replace( '/\{[A-Z_]+\}/', '[N/A]', $text );
414 return $text;
415 }
416 #endregion
417
418 #region Security Helpers
419 /**
420 * Sanitize file path to prevent PHAR deserialization attacks.
421 * Strips dangerous stream wrappers that could trigger object injection.
422 *
423 * @param string $path The file path to sanitize.
424 * @return string The sanitized file path.
425 * @throws Exception If a dangerous stream wrapper is detected.
426 */
427 public static function sanitize_file_path( $path ) {
428 if ( empty( $path ) || !is_string( $path ) ) {
429 return $path;
430 }
431
432 // List of dangerous stream wrappers that could trigger deserialization
433 $dangerous_wrappers = [
434 'phar://',
435 'php://',
436 'zip://',
437 'zlib://',
438 'data://',
439 'glob://',
440 'rar://',
441 'ogg://',
442 'expect://'
443 ];
444
445 // Check if the path contains any dangerous wrappers
446 $lower_path = strtolower( $path );
447 foreach ( $dangerous_wrappers as $wrapper ) {
448 if ( strpos( $lower_path, $wrapper ) === 0 ) {
449 throw new Exception( 'Invalid file path: Stream wrappers are not allowed for security reasons.' );
450 }
451 }
452
453 return $path;
454 }
455 #endregion
456
457 #region Image-Related Helpers
458 public static function is_image( $file ) {
459 global $mwai_core;
460 if ( $mwai_core && $mwai_core->imageService ) {
461 return $mwai_core->imageService->is_image( $file );
462 }
463 // Fallback to original implementation if service not available
464 $mimeType = self::get_mime_type( $file );
465 if ( strpos( $mimeType, 'image' ) !== false ) {
466 return true;
467 }
468 return false;
469 }
470
471 public static function get_image_resolution( $url ) {
472 global $mwai_core;
473 if ( $mwai_core && $mwai_core->imageService ) {
474 return $mwai_core->imageService->get_image_resolution( $url );
475 }
476 // Fallback to original implementation if service not available
477 if ( empty( $url ) ) {
478 return null;
479 }
480 $headers = get_headers( $url, 1 );
481 if ( strpos( $headers[0], '200' ) === false ) {
482 return null;
483 }
484 $image_info = getimagesize( $url );
485 if ( $image_info === false ) {
486 return null;
487 }
488 return [
489 'width' => $image_info[0],
490 'height' => $image_info[1]
491 ];
492 }
493
494 public static function get_mime_type( $file ) {
495 global $mwai_core;
496 if ( $mwai_core && $mwai_core->imageService ) {
497 return $mwai_core->imageService->get_mime_type( $file );
498 }
499
500 // Fallback implementation - this should rarely be used as imageService is initialized early
501 Meow_MWAI_Logging::warn( 'get_mime_type called before imageService is available' );
502
503 // Basic extension-based detection only
504 $extension = pathinfo( $file, PATHINFO_EXTENSION );
505 $extension = strtolower( $extension );
506 $mimeTypes = [
507 'jpg' => 'image/jpeg',
508 'jpeg' => 'image/jpeg',
509 'png' => 'image/png',
510 'gif' => 'image/gif',
511 'webp' => 'image/webp',
512 'bmp' => 'image/bmp',
513 'tiff' => 'image/tiff',
514 'tif' => 'image/tiff',
515 'svg' => 'image/svg+xml',
516 'ico' => 'image/x-icon',
517 'pdf' => 'application/pdf',
518 ];
519 return isset( $mimeTypes[$extension] ) ? $mimeTypes[$extension] : null;
520 }
521
522 public function download_image( $url ) {
523 return $this->imageService->download_image( $url );
524 }
525
526 /**
527 * Add an image from a URL to the Media Library.
528 * @param string $url The URL of the image to be downloaded.
529 * @param string $filename The filename of the image, if not set, it will be the basename of the URL.
530 * @param string $title The title of the image.
531 * @param string $description The description of the image.
532 * @param string $caption The caption of the image.
533 * @param string $alt The alt text of the image.
534 * @return int The attachment ID of the image.
535 */
536 public function add_image_from_url( $url, $filename = null, $title = null, $description = null, $caption = null, $alt = null, $attachedPost = null, $post_status = 'inherit', $post_type = 'attachment', $ai_metadata = [] ) {
537 return $this->imageService->add_image_from_url( $url, $filename, $title, $description, $caption, $alt, $attachedPost, $post_status, $post_type, $ai_metadata );
538 }
539 #endregion
540
541 #region Context-Related Helpers
542 public function retrieve_context( $params, $query, $streamCallback = null ) {
543 $contextMaxLength = $params['contextMaxLength'] ?? $this->get_option( 'context_max_length', 4096 );
544 $embeddingsEnvId = $params['embeddingsEnvId'] ?? null;
545
546 $context = apply_filters( 'mwai_context_search', [], $query, [
547 'embeddingsEnvId' => $embeddingsEnvId,
548 'streamCallback' => $streamCallback
549 ] );
550
551 // Emit embeddings event if streaming and context was found
552 if ( $streamCallback && !empty( $context ) ) {
553 $count = 0;
554 if ( isset( $context['embeddings'] ) && is_array( $context['embeddings'] ) ) {
555 $count = count( $context['embeddings'] );
556 }
557 else if ( isset( $context['content'] ) ) {
558 $count = 1;
559 }
560 if ( $count > 0 ) {
561 $event = Meow_MWAI_Event::embeddings( $count );
562 $streamCallback( $event );
563 }
564 }
565
566 if ( empty( $context ) ) {
567 return null;
568 }
569 else if ( !isset( $context['content'] ) ) {
570 Meow_MWAI_Logging::warn( 'A context without content was returned.' );
571 return null;
572 }
573 $context['content'] = $this->clean_sentences( $context['content'], $contextMaxLength );
574 $context['length'] = strlen( $context['content'] );
575 return $context;
576 }
577
578 /**
579 * Wrap context content with framing instructions for AI.
580 * This helps the AI understand that the context is background knowledge.
581 *
582 * @param string $context The raw context content.
583 * @return string The framed context with instructions.
584 */
585 public function frame_context( $context ) {
586 if ( empty( $context ) ) {
587 return $context;
588 }
589 $framing = 'The following is your knowledge about this topic. ' .
590 'Use it naturally when relevant - never mention or acknowledge that this information was provided to you. ' .
591 "If the user's message is unrelated (e.g., greetings, thanks), respond naturally without using it.";
592 $framing = apply_filters( 'mwai_context_framing', $framing, $context );
593 return $framing . "\n\n---\n" . $context . "\n---";
594 }
595 #endregion
596
597 #region Users/Sessions Helpers
598
599 public function get_nonce( $force = false ) {
600 return $this->sessionService->get_nonce( $force );
601 }
602
603 // This is a bit hacky, but chatId needs to be retrieved or generated.
604 // Maybe we can clean this up later.
605 public function fix_chat_id( $query, $params ) {
606 return $this->sessionService->fix_chat_id( $query, $params );
607 }
608
609 public function get_session_id() {
610 return $this->sessionService->get_session_id();
611 }
612
613 /**
614 * Get the Response ID Manager service
615 */
616 public function get_response_id_manager() {
617 return $this->responseIdManager;
618 }
619
620 /**
621 * Get the Message Builder service
622 */
623 public function get_message_builder() {
624 return $this->messageBuilder;
625 }
626
627 // Get the UserID from the data, or from the current user
628 public function get_user_id( $data = null ) {
629 return $this->sessionService->get_user_id( $data );
630 }
631
632 public function get_session_user_id() {
633 return $this->sessionService->get_session_user_id();
634 }
635
636 public function get_admin_user() {
637 return $this->sessionService->get_admin_user();
638 }
639
640 public function get_user_data() {
641 return $this->sessionService->get_user_data();
642 }
643
644 public function get_ip_address( $force = false ) {
645 return $this->sessionService->get_ip_address( $force );
646 }
647
648 #endregion
649
650 #region Sanitization
651 public function sanitize_sort(
652 &$sort,
653 $default_accessor = 'created',
654 $default_order = 'DESC',
655 $allowed_columns = [ 'created', 'updated', 'name', 'id', 'time', 'units', 'price' ]
656 ) {
657
658 // Ensure $sort is an array
659 if ( !is_array( $sort ) ) {
660 $sort = [ 'accessor' => $default_accessor, 'by' => $default_order ];
661 }
662 // Extract and sanitize the accessor
663 $sort_accessor = isset( $sort['accessor'] ) ? $sort['accessor'] : $default_accessor;
664 if ( !in_array( $sort_accessor, $allowed_columns ) ) {
665 Meow_MWAI_Logging::error( "This sort accessor is not allowed ($sort_accessor)." );
666 $sort_accessor = $default_accessor;
667 }
668 // Extract and sanitize the sort order
669 $sort_by = isset( $sort['by'] ) ? strtoupper( $sort['by'] ) : $default_order;
670 if ( $sort_by !== 'ASC' && $sort_by !== 'DESC' ) {
671 Meow_MWAI_Logging::error( "This sort order is not allowed ($sort_by)." );
672 $sort_by = $default_order;
673 }
674 // Update the sort array with sanitized values
675 $sort['accessor'] = $sort_accessor;
676 $sort['by'] = $sort_by;
677 }
678 #endregion
679
680 #region Other Helpers
681 public function safe_strlen( $string, $encoding = 'UTF-8' ) {
682 if ( function_exists( 'mb_strlen' ) ) {
683 return mb_strlen( $string, $encoding );
684 }
685 else {
686 // Fallback implementation for environments without mbstring extension
687 return preg_match_all( '/./u', $string, $matches );
688 }
689 }
690
691 public function check_rest_nonce( $request ) {
692 // REST NONCE VERIFICATION:
693 // Validates nonce from X-WP-Nonce header using WordPress nonce system.
694 // Returns: false (invalid), 1 (0-12 hours old), or 2 (12-24 hours old)
695 // WordPress REST permission callbacks accept any truthy value as success.
696 // The filter allows custom authorization logic if needed.
697 $nonce = $request->get_header( 'X-WP-Nonce' );
698 $rest_nonce = wp_verify_nonce( $nonce, 'wp_rest' );
699 return apply_filters( 'mwai_rest_authorized', $rest_nonce, $request );
700 }
701
702 public function get_random_id( $length = 8, $excludeIds = [] ) {
703 $characters = '0123456789abcdefghijklmnopqrstuvwxyz';
704 $charactersLength = strlen( $characters );
705 $randomId = '';
706 for ( $i = 0; $i < $length; $i++ ) {
707 $randomId .= $characters[ mt_rand( 0, $charactersLength - 1 ) ];
708 }
709 if ( in_array( $randomId, $excludeIds ) ) {
710 return $this->get_random_id( $length, $excludeIds );
711 }
712 return $randomId;
713 }
714
715 public function is_url( $url ) {
716 return strpos( $url, 'http' ) === 0 ? true : false;
717 }
718
719 public function get_post_types() {
720 $excluded = [ 'attachment', 'revision', 'nav_menu_item' ];
721 $post_types = [];
722 $types = get_post_types( [], 'objects' );
723
724 // Let's get the Post Types that are enabled for Embeddings Sync
725 $embeddingsSettings = $this->get_option( 'embeddings' );
726 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
727
728 foreach ( $types as $type ) {
729 $forced = in_array( $type->name, $syncPostTypes );
730 // Should not be excluded.
731 if ( !$forced && in_array( $type->name, $excluded ) ) {
732 continue;
733 }
734 // Should be public.
735 if ( !$forced && !$type->public ) {
736 continue;
737 }
738 $post_types[] = [
739 'name' => $type->labels->name,
740 'type' => $type->name,
741 ];
742 }
743
744 // Let's get the Post Types that are enabled for Embeddings Sync
745 $embeddingsSettings = $this->get_option( 'embeddings' );
746 $syncPostTypes = isset( $embeddingsSettings['syncPostTypes'] ) ? $embeddingsSettings['syncPostTypes'] : [];
747
748 return $post_types;
749 }
750
751 public function get_post( $post ) {
752 if ( is_numeric( $post ) ) {
753 // Force fresh retrieval to avoid cache issues
754 clean_post_cache( $post );
755 $post = get_post( $post );
756 }
757 if ( is_object( $post ) ) {
758 $post = (array) $post;
759 }
760 if ( !is_array( $post ) ) {
761 return null;
762 }
763 $language = $this->get_post_language( $post['ID'] );
764 $content = $this->get_post_content( $post['ID'] );
765 $title = $post['post_title'];
766 $excerpt = $post['post_excerpt'];
767 $url = get_permalink( $post['ID'] );
768 $checksum = wp_hash( $content . $title . $url );
769
770 return [
771 'postId' => (int) $post['ID'],
772 'title' => $title,
773 'content' => $content,
774 'excerpt' => $excerpt,
775 'url' => $url,
776 'language' => $language ?? 'english',
777 'checksum' => $checksum,
778 ];
779 }
780
781 /**
782 * Format a date/time string into a human-readable format
783 * @param string $date_string The date string to format
784 * @return string Formatted date (e.g., "Just now", "5m ago", "2h ago", "3d ago", "Jan 20th")
785 */
786 public function format_discussion_date( $date_string ) {
787 $date = strtotime( $date_string );
788 $now = time();
789 $diff = $now - $date;
790
791 // Less than a minute
792 if ( $diff < 60 ) {
793 return 'Just now';
794 }
795
796 // Less than an hour
797 if ( $diff < 3600 ) {
798 $minutes = floor( $diff / 60 );
799 return $minutes . 'm ago';
800 }
801
802 // Less than a day
803 if ( $diff < 86400 ) {
804 $hours = floor( $diff / 3600 );
805 return $hours . 'h ago';
806 }
807
808 // Less than a week
809 if ( $diff < 604800 ) {
810 $days = floor( $diff / 86400 );
811 return $days . 'd ago';
812 }
813
814 // Format as date
815 $is_current_year = date( 'Y', $date ) === date( 'Y', $now );
816 if ( $is_current_year ) {
817 return date( 'M jS', $date );
818 }
819 else {
820 return date( 'M jS, Y', $date );
821 }
822 }
823 #endregion
824
825 #region Usage & Costs
826
827 // Quick and dirty token estimation
828 // Let's keep this synchronized with Helpers in JS
829 public static function estimate_tokens( $text = '', $model = null ): int {
830 global $mwai_core;
831 if ( $mwai_core && $mwai_core->usageStatsService ) {
832 return $mwai_core->usageStatsService->estimate_tokens( $text, $model );
833 }
834 // Fallback to original implementation if service not available
835 if ( !is_string( $text ) ) {
836 $text = is_array( $text ) || is_object( $text ) ? json_encode( $text ) : (string) $text;
837 }
838 $averageTokenLength = 4;
839 $words = preg_split( '/\s+/', trim( $text ) );
840 $tokenCount = 0;
841 foreach ( $words as $word ) {
842 $tokenCount += ceil( strlen( $word ) / $averageTokenLength );
843 }
844 return apply_filters( 'mwai_estimate_tokens', $tokenCount, $text );
845 }
846
847 public function record_tokens_usage( $model, $in_tokens, $out_tokens = 0, $returned_price = null ) {
848 return $this->usageStatsService->record_tokens_usage( $model, $in_tokens, $out_tokens, $returned_price );
849 }
850
851 public function record_audio_usage( $model, $seconds ) {
852 return $this->usageStatsService->record_audio_usage( $model, $seconds );
853 }
854
855 public function record_images_usage( $model, $resolution, $images ) {
856 return $this->usageStatsService->record_images_usage( $model, $resolution, $images );
857 }
858
859 public function record_videos_usage( $model, $resolution, $seconds ) {
860 return $this->usageStatsService->record_videos_usage( $model, $resolution, $seconds );
861 }
862
863 #endregion
864
865 #region Streaming
866 public function stream_push( $data, $query = null ) {
867 try {
868 // Handle new Event objects
869 if ( is_object( $data ) && method_exists( $data, 'to_array' ) ) {
870 $data = $data->to_array();
871 }
872
873 $data = apply_filters( 'mwai_stream_push', $data, $query );
874 $out = 'data: ' . json_encode( $data );
875 echo $out;
876 echo "\n\n";
877 if ( ob_get_level() > 0 ) {
878 ob_end_flush();
879 }
880 flush();
881 }
882 catch ( Exception $e ) {
883 // Send error as proper SSE error event
884 $errorMessage = 'Oops! Something went wrong on the server. Please try again, and if you are the site developer, check the PHP Error Logs for details.';
885 error_log( '[AI Engine Stream Error] ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
886
887 $errorData = [
888 'type' => 'error',
889 'data' => $errorMessage
890 ];
891 $out = 'data: ' . json_encode( $errorData );
892 echo $out;
893 echo "\n\n";
894 if ( ob_get_level() > 0 ) {
895 ob_end_flush();
896 }
897 flush();
898
899 // Stop execution after sending error
900 die();
901 }
902 }
903 #endregion
904
905 #region Options
906 public function get_themes() {
907 $themes = get_option( $this->themes_option_name, [] );
908 $themes = empty( $themes ) ? [] : $themes;
909
910 $internalThemes = [
911 'chatgpt' => [
912 'type' => 'internal', 'name' => 'ChatGPT', 'themeId' => 'chatgpt',
913 'settings' => [], 'style' => ''
914 ],
915 'messages' => [
916 'type' => 'internal', 'name' => 'Messages', 'themeId' => 'messages',
917 'settings' => [], 'style' => ''
918 ],
919 'timeless' => [
920 'type' => 'internal', 'name' => 'Timeless', 'themeId' => 'timeless',
921 'settings' => [], 'style' => ''
922 ],
923 'foundation' => [
924 'type' => 'internal', 'name' => 'Foundation', 'themeId' => 'foundation',
925 'settings' => [], 'style' => ''
926 ],
927 ];
928 $customThemes = [];
929 foreach ( $themes as $theme ) {
930 if ( isset( $internalThemes[$theme['themeId']] ) ) {
931 $internalThemes[$theme['themeId']] = $theme;
932 continue;
933 }
934 $customThemes[] = $theme;
935 }
936 return array_merge( array_values( $internalThemes ), $customThemes );
937 }
938
939 public function update_themes( $themes ) {
940 update_option( $this->themes_option_name, $themes );
941 return $themes;
942 }
943
944 /**
945 * Returns the registered chatbots. Pass a $filters array to narrow the list,
946 * e.g. get_chatbots( [ 'functions' => true ] ) to only keep chatbots whose
947 * model supports function calling. No filters returns every chatbot, as before.
948 */
949 public function get_chatbots( $filters = [] ) {
950 $chatbots = get_option( $this->chatbots_option_name, [] );
951 $hasChanges = false;
952 if ( empty( $chatbots ) ) {
953 $chatbots = [ array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] ) ];
954 }
955 $hasDefault = false;
956 foreach ( $chatbots as &$chatbot ) {
957 if ( $chatbot['botId'] === 'default' ) {
958 $hasDefault = true;
959 }
960 foreach ( MWAI_CHATBOT_DEFAULT_PARAMS as $key => $value ) {
961 // Use default value if not set.
962 if ( !isset( $chatbot[$key] ) ) {
963 $chatbot[$key] = $value;
964 }
965 }
966
967 /*
968 This is the best section to rename fields.
969 We did this in 2024 for context to instructions, and fileUpload to fileSearch. fileSearch is for assistant file search, and fileUpload is now for chatbot file upload (similar to vision, but for files instead of images).
970 */
971
972 // Migrate old file upload params to new unified system
973 if ( !isset( $chatbot['fileUpload'] ) ) {
974 // Set fileUpload based on old params (imageUpload, fileUploads, or vision checkbox)
975 $chatbot['fileUpload'] = !empty( $chatbot['imageUpload'] ) || ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 );
976 $hasChanges = true;
977 }
978
979 // Ensure maxUploads is set
980 if ( !isset( $chatbot['maxUploads'] ) ) {
981 if ( isset( $chatbot['fileUploads'] ) && $chatbot['fileUploads'] > 0 ) {
982 $chatbot['maxUploads'] = $chatbot['fileUploads'];
983 }
984 else {
985 $chatbot['maxUploads'] = 1; // Default to 1 file
986 }
987 $hasChanges = true;
988 }
989
990 // Sync fileUploads with fileUpload and maxUploads for consistency
991 if ( isset( $chatbot['fileUpload'] ) ) {
992 $chatbot['fileUploads'] = $chatbot['fileUpload'] ? $chatbot['maxUploads'] : 0;
993 $chatbot['multiUpload'] = $chatbot['fileUpload'] && $chatbot['maxUploads'] > 1;
994 $chatbot['imageUpload'] = $chatbot['fileUpload']; // Keep imageUpload in sync
995 }
996
997 // Migration: DALL-E was removed (deprecated by OpenAI). Move chatbots to gpt-image-1.5.
998 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
999 if ( isset( $chatbot['model'] )
1000 && in_array( $chatbot['model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
1001 $chatbot['model'] = MWAI_FALLBACK_MODEL_IMAGES;
1002 $hasChanges = true;
1003 }
1004
1005 // if ( isset( $chatbot['context'] ) ) {
1006 // $chatbot['instructions'] = $chatbot['context'];
1007 // unset( $chatbot['context'] );
1008 // $hasChanges = true;
1009 // }
1010 }
1011 if ( !$hasDefault ) {
1012 $defaultBot = array_merge( MWAI_CHATBOT_DEFAULT_PARAMS, ['name' => 'Default', 'botId' => 'default' ] );
1013 array_unshift( $chatbots, $defaultBot );
1014 $hasChanges = true;
1015 }
1016 if ( $hasChanges ) {
1017 update_option( $this->chatbots_option_name, $chatbots );
1018 }
1019
1020 // Optional filtering by capability (e.g. only function-calling chatbots).
1021 if ( !empty( $filters['functions'] ) ) {
1022 $chatbots = array_values( array_filter( $chatbots, function ( $chatbot ) {
1023 return $this->chatbot_supports_functions( $chatbot );
1024 } ) );
1025 }
1026
1027 return $chatbots;
1028 }
1029
1030 /**
1031 * Whether a chatbot's model supports function calling. The 'functions' tag is
1032 * the canonical signal across all engines; dynamic providers (OpenRouter, etc.)
1033 * also expose it as a feature, so we accept both.
1034 */
1035 public function chatbot_supports_functions( $chatbot ) {
1036 $modelId = $chatbot['model'] ?? null;
1037 if ( empty( $modelId ) ) {
1038 return false;
1039 }
1040 $envId = $chatbot['envId'] ?? ( $chatbot['environment'] ?? null );
1041 $model = $this->find_model_data( $modelId, $envId );
1042 if ( empty( $model ) ) {
1043 return false;
1044 }
1045 $tags = $model['tags'] ?? [];
1046 $features = $model['features'] ?? [];
1047 return in_array( 'functions', $tags, true ) || in_array( 'functions', $features, true );
1048 }
1049
1050 /**
1051 * Resolve a model id to its metadata (tags, features, etc.). Static providers
1052 * (OpenAI, Anthropic) keep their models in 'ai_engines' by type; dynamic ones
1053 * (OpenRouter, Google) store them per environment in 'ai_envs'. We check the
1054 * environment first, then fall back to the engine defaults and custom models.
1055 */
1056 public function find_model_data( $modelId, $envId = null ) {
1057 if ( empty( $modelId ) ) {
1058 return null;
1059 }
1060 $options = $this->get_all_options();
1061
1062 // 1. The chatbot's own environment (covers dynamically-fetched models).
1063 $envType = null;
1064 if ( !empty( $envId ) && !empty( $options['ai_envs'] ) ) {
1065 foreach ( $options['ai_envs'] as $env ) {
1066 if ( ( $env['id'] ?? null ) !== $envId ) {
1067 continue;
1068 }
1069 $envType = $env['type'] ?? null;
1070 foreach ( $env['models'] ?? [] as $model ) {
1071 if ( ( $model['model'] ?? null ) === $modelId ) {
1072 return $model;
1073 }
1074 }
1075 }
1076 }
1077
1078 // 2. The engine defaults (constants), scoped to the env type when known.
1079 foreach ( $options['ai_engines'] ?? [] as $engine ) {
1080 if ( $envType !== null && ( $engine['type'] ?? null ) !== $envType ) {
1081 continue;
1082 }
1083 foreach ( $engine['models'] ?? [] as $model ) {
1084 if ( ( $model['model'] ?? null ) === $modelId ) {
1085 return $model;
1086 }
1087 }
1088 }
1089
1090 // 3. Custom models.
1091 foreach ( $options['ai_models'] ?? [] as $model ) {
1092 if ( ( $model['model'] ?? null ) === $modelId ) {
1093 return $model;
1094 }
1095 }
1096
1097 return null;
1098 }
1099
1100 public function get_chatbot( $botId ) {
1101 $chatbots = $this->get_chatbots();
1102 foreach ( $chatbots as $chatbot ) {
1103 if ( $chatbot['botId'] === (string) $botId ) {
1104 return $chatbot;
1105 }
1106 }
1107 return null;
1108 }
1109
1110 public function get_embeddings_env( $envId ) {
1111 return $this->modelEnvironmentService->get_embeddings_env( $envId );
1112 }
1113
1114 public function get_ai_env( $envId ) {
1115 return $this->modelEnvironmentService->get_ai_env( $envId );
1116 }
1117
1118 public function get_assistant( $envId, $assistantId ) {
1119 return $this->modelEnvironmentService->get_assistant( $envId, $assistantId );
1120 }
1121
1122 public function get_theme( $themeId ) {
1123 $themes = $this->get_themes();
1124 foreach ( $themes as $theme ) {
1125 if ( $theme['themeId'] === $themeId ) {
1126 // Append custom CSS to theme data for frontend rendering (check for non-empty trimmed string)
1127 if ( isset( $theme['settings']['customCSS'] ) && trim( $theme['settings']['customCSS'] ) !== '' ) {
1128 $customCSS = $theme['settings']['customCSS'];
1129
1130 // Add theme class prefix to all CSS rules for proper scoping
1131 $themeClass = '.mwai-' . $themeId . '-theme';
1132 $lines = explode( "\n", $customCSS );
1133 $processedCSS = '';
1134 $inRule = false;
1135
1136 foreach ( $lines as $line ) {
1137 $trimmedLine = trim( $line );
1138
1139 // Skip empty lines and comments
1140 if ( empty( $trimmedLine ) || strpos( $trimmedLine, '/*' ) === 0 ) {
1141 $processedCSS .= $line . "\n";
1142 continue;
1143 }
1144
1145 // If line contains a selector (has { but not })
1146 if ( strpos( $line, '{' ) !== false && strpos( $line, '}' ) === false ) {
1147 // Extract selector and the rest
1148 $parts = explode( '{', $line, 2 );
1149 $selector = trim( $parts[0] );
1150
1151 // Add theme class prefix if not already present
1152 if ( strpos( $selector, $themeClass ) !== 0 ) {
1153 // Handle multiple selectors separated by comma
1154 $selectors = explode( ',', $selector );
1155 $prefixedSelectors = array_map( function ( $sel ) use ( $themeClass ) {
1156 $sel = trim( $sel );
1157 // Don't prefix if it's a keyframe or similar
1158 if ( strpos( $sel, '@' ) === 0 || strpos( $sel, 'from' ) === 0 || strpos( $sel, 'to' ) === 0 || preg_match( '/^\d+%/', $sel ) ) {
1159 return $sel;
1160 }
1161 return $themeClass . ' ' . $sel;
1162 }, $selectors );
1163 $selector = implode( ', ', $prefixedSelectors );
1164 }
1165
1166 $processedCSS .= $selector . ' {' . ( isset( $parts[1] ) ? $parts[1] : '' ) . "\n";
1167 $inRule = true;
1168 }
1169 else {
1170 $processedCSS .= $line . "\n";
1171 }
1172 }
1173
1174 $customCSS = $processedCSS;
1175
1176 // For custom themes (type: 'css'), append to style property
1177 if ( $theme['type'] === 'css' ) {
1178 $theme['style'] = ( $theme['style'] ?? '' ) . "\n\n/* Custom CSS */\n" . $customCSS;
1179 }
1180 // For internal themes, add customCSS as a separate property
1181 else {
1182 $theme['customCSS'] = $customCSS;
1183 }
1184 }
1185
1186 // Add CSS URL for cross-site and dynamic loading support
1187 // Internal themes can use physical file, custom themes use REST endpoint
1188 $theme_type = $theme['type'] ?? 'internal';
1189 if ( $theme_type === 'internal' ) {
1190 $theme['cssUrl'] = MWAI_URL . 'themes/' . $themeId . '.css';
1191 }
1192 else {
1193 // Custom themes use REST endpoint (requires Cross-Site module to be enabled)
1194 $theme['cssUrl'] = get_rest_url( null, 'mwai-ui/v1/cross-site/theme-css' ) . '?themeId=' . $themeId;
1195 }
1196
1197 return $theme;
1198 }
1199 }
1200 return null;
1201 }
1202
1203 public function update_chatbots( $chatbots ) {
1204 $htmlFields = [ 'instructions', 'textCompliance', 'aiName', 'userName', 'startSentence' ];
1205 $keepLineReturnsFields = [ 'instructions' ];
1206 $whiteSpacedFields = [ 'context' ];
1207 // Boolean fields that need proper conversion
1208 $booleanFields = [ 'window', 'copyButton', 'pdfButton', 'fullscreen', 'localMemory', 'iconBubble', 'centerOpen',
1209 'imageUpload', 'fileUpload', 'multiUpload', 'fileSearch', 'contentAware', 'aiAvatar', 'userAvatar', 'guestAvatar' ];
1210 foreach ( $chatbots as &$chatbot ) {
1211 foreach ( $chatbot as $key => &$value ) {
1212 if ( in_array( $key, $htmlFields ) ) {
1213 $value = wp_kses_post( $value );
1214 }
1215 else if ( in_array( $key, $whiteSpacedFields ) ) {
1216 $value = sanitize_textarea_field( $value );
1217 }
1218 else if ( in_array( $key, $booleanFields ) ) {
1219 // Convert various representations to boolean
1220 if ( is_bool( $value ) ) {
1221 // Already boolean, keep as is
1222 }
1223 else if ( $value === 1 || $value === '1' || $value === true || $value === 'true' || $value === 'yes' ) {
1224 // These are true values
1225 $value = true;
1226 }
1227 else if ( $value === 0 || $value === '0' || $value === false || $value === 'false' || $value === 'no' || $value === '' || $value === null ) {
1228 // These are false values
1229 $value = false;
1230 }
1231 else {
1232 // Default to checking if not empty
1233 $value = !empty( $value );
1234 }
1235 }
1236 else if ( $key === 'functions' ) {
1237 $functions = [];
1238 foreach ( $value as $function ) {
1239 if ( isset( $function['id'] ) && isset( $function['type'] ) ) {
1240 $functions[] = [
1241 'id' => sanitize_text_field( $function['id'] ),
1242 'type' => sanitize_text_field( $function['type'] ),
1243 ];
1244 }
1245 }
1246 $value = $functions;
1247 }
1248 else if ( $key === 'mcpServers' ) {
1249 $mcpServers = [];
1250 foreach ( $value as $server ) {
1251 if ( isset( $server['id'] ) ) {
1252 $mcpServers[] = [
1253 'id' => sanitize_text_field( $server['id'] ),
1254 ];
1255 }
1256 }
1257 $value = $mcpServers;
1258 }
1259 else if ( $key === 'tools' ) {
1260 // Sanitize tools array (web_search, image_generation, thinking, etc)
1261 $tools = [];
1262 if ( is_array( $value ) ) {
1263 foreach ( $value as $tool ) {
1264 $sanitized_tool = sanitize_text_field( $tool );
1265 if ( in_array( $sanitized_tool, ['web_search', 'image_generation', 'thinking', 'code_interpreter', 'google_maps'] ) ) {
1266 $tools[] = $sanitized_tool;
1267 }
1268 }
1269 }
1270 $value = $tools;
1271 }
1272 else if ( $key === 'crossSite' ) {
1273 // Handle crossSite object
1274 $crossSite = [
1275 'enabled' => isset( $value['enabled'] ) ? (bool) $value['enabled'] : false,
1276 'allowedDomains' => []
1277 ];
1278 if ( isset( $value['allowedDomains'] ) && is_array( $value['allowedDomains'] ) ) {
1279 foreach ( $value['allowedDomains'] as $domain ) {
1280 $sanitized_domain = sanitize_text_field( $domain );
1281 if ( !empty( $sanitized_domain ) ) {
1282 $crossSite['allowedDomains'][] = $sanitized_domain;
1283 }
1284 }
1285 }
1286 $value = $crossSite;
1287 }
1288 else {
1289 if ( in_array( $key, $keepLineReturnsFields ) ) {
1290 $value = preg_replace( '/\r\n/', '[==LINE_RETURN==]', $value );
1291 $value = preg_replace( '/\n/', '[==LINE_RETURN==]', $value );
1292 }
1293 $value = sanitize_text_field( $value );
1294 if ( in_array( $key, $keepLineReturnsFields ) ) {
1295 $value = preg_replace( '/\[==LINE_RETURN==\]/', "\n", $value );
1296 }
1297 }
1298 }
1299
1300 // Sync upload params to ensure consistency
1301 // fileUpload is the master control - respect its value
1302 $fileUploadEnabled = !empty( $chatbot['fileUpload'] ) || !empty( $chatbot['imageUpload'] );
1303 $maxFiles = isset( $chatbot['maxUploads'] ) && $chatbot['maxUploads'] > 0 ? (int) $chatbot['maxUploads'] : 1;
1304
1305 $chatbot['imageUpload'] = $fileUploadEnabled;
1306 $chatbot['fileUploads'] = $fileUploadEnabled ? $maxFiles : 0;
1307 $chatbot['multiUpload'] = $fileUploadEnabled && $maxFiles > 1;
1308 }
1309 if ( !update_option( $this->chatbots_option_name, $chatbots ) ) {
1310 Meow_MWAI_Logging::warn( 'Could not update chatbots.' );
1311 $chatbots = get_option( $this->chatbots_option_name, [] );
1312 return $chatbots;
1313 }
1314 return $chatbots;
1315 }
1316
1317 public function populate_dynamic_options( $options ) {
1318 static $populating = false;
1319
1320 // Prevent infinite recursion
1321 if ( $populating ) {
1322 return $options;
1323 }
1324
1325 $populating = true;
1326
1327 // Languages - use custom languages as the complete list
1328 $custom_languages = isset( $options['custom_languages'] ) && !empty( $options['custom_languages'] )
1329 ? $options['custom_languages']
1330 : [];
1331
1332 // If no custom languages defined, fall back to defaults
1333 if ( empty( $custom_languages ) ) {
1334 $options['languages'] = apply_filters( 'mwai_languages', MWAI_LANGUAGES );
1335 }
1336 else {
1337 // Process custom languages
1338 $processed_languages = [];
1339 foreach ( $custom_languages as $custom_lang ) {
1340 // Support formats like "Russian (ru)" or just "Russian"
1341 $custom_lang = trim( $custom_lang );
1342 if ( !empty( $custom_lang ) ) {
1343 // Check if language code is provided in parentheses
1344 if ( preg_match( '/^(.+)\s*\(([a-z]{2,3})\)$/i', $custom_lang, $matches ) ) {
1345 $lang_name = trim( $matches[1] );
1346 $lang_code = strtolower( trim( $matches[2] ) );
1347 $processed_languages[$lang_code] = $lang_name;
1348 }
1349 else {
1350 // No code provided, add as-is
1351 $processed_languages[] = $custom_lang;
1352 }
1353 }
1354 }
1355
1356 $options['languages'] = apply_filters( 'mwai_languages', $processed_languages );
1357 }
1358
1359 // Consolidate the Engines and their Models
1360 // PS: We should ABSOLUTELY AVOID to use ai_models directly (except for saving)
1361 // Engine Example: [ 'name' => 'Ollama', 'type' => 'ollama', inputs => ['apikey', 'endpoint'], models => [] ]
1362 $engines = MWAI_ENGINES;
1363
1364 // OVHcloud is integrated but not yet promoted publicly (pending the OVHcloud
1365 // partnership). It only appears as a selectable provider when Dev Mode is on,
1366 // so production sites never see it until we decide to ship it.
1367 if ( $this->get_option( 'dev_mode' ) ) {
1368 $engines[] = [
1369 'name' => 'OVHcloud',
1370 'type' => 'ovh',
1371 'inputs' => [ 'apikey', 'dynamicModels' ],
1372 'internal' => true,
1373 'models' => [],
1374 ];
1375 }
1376
1377 $options['ai_engines'] = apply_filters( 'mwai_engines', $engines );
1378 foreach ( $options['ai_engines'] as &$engine ) {
1379 if ( $engine['type'] === 'openai' ) {
1380 $engine['models'] = apply_filters(
1381 'mwai_openai_models',
1382 Meow_MWAI_Engines_OpenAI::get_models_static()
1383 );
1384 }
1385 else if ( $engine['type'] === 'anthropic' ) {
1386 $engine['models'] = apply_filters(
1387 'mwai_anthropic_models',
1388 Meow_MWAI_Engines_Anthropic::get_models_static()
1389 );
1390 }
1391 else if ( $engine['type'] === 'perplexity' ) {
1392 $engine['models'] = apply_filters(
1393 'mwai_perplexity_models',
1394 Meow_MWAI_Engines_Perplexity::get_models_static()
1395 );
1396 }
1397 else if ( $engine['type'] === 'mistral' ) {
1398 $engine['models'] = apply_filters(
1399 'mwai_mistral_models',
1400 Meow_MWAI_Engines_Mistral::get_models_static()
1401 );
1402 }
1403 else if ( $engine['type'] === 'xai' ) {
1404 // Static fallback covers the case where dynamic model fetch failed (e.g. no credits
1405 // on the xAI account). Dynamically fetched models override this list when available.
1406 $engine['models'] = apply_filters(
1407 'mwai_xai_models',
1408 Meow_MWAI_Engines_XAI::get_models_static()
1409 );
1410 }
1411 else {
1412 $engine['models'] = [];
1413 foreach ( $options['ai_models'] as $model ) {
1414 if ( $model['type'] === $engine['type'] ) {
1415 $engine['models'][] = $model;
1416 }
1417 }
1418 }
1419 }
1420
1421 // Functions via Code Engine (or custom code)
1422 $json = [];
1423 $functions = apply_filters( 'mwai_functions_list', [] );
1424 foreach ( $functions as $function ) {
1425 if ( $function->type === 'editor-assistant' ) {
1426 continue;
1427 }
1428 $json[] = Meow_MWAI_Query_Function::toJson( $function );
1429 }
1430 $options['functions'] = $json;
1431
1432 // Addons
1433 $options['addons'] = apply_filters( 'mwai_addons', [
1434 [
1435 'slug' => 'mwai-notifications',
1436 'name' => 'Notifications',
1437 'description' => 'Get real-time alerts for new discussions in your chatbot, so you never miss a chance to engage.',
1438 'install_url' => 'https://meowapps.com/products/mwai-notifications/',
1439 'settings_url' => null,
1440 'stars' => 4,
1441 'enabled' => false
1442 ],
1443 [
1444 'slug' => 'mwai-ollama',
1445 'name' => 'Ollama',
1446 'description' => 'Leverage local LLM integration through Ollama; refresh and use your own models for a flexible, cost-free approach.',
1447 'install_url' => 'https://meowapps.com/products/mwai-ollama/',
1448 'settings_url' => null,
1449 'stars' => 3,
1450 'enabled' => false
1451 ],
1452 [
1453 'slug' => 'mwai-deepseek',
1454 'name' => 'DeepSeek',
1455 'description' => 'Support for DeepSeek, a Chinese AI company that provides extremely powerful LLM models.',
1456 'install_url' => 'https://meowapps.com/products/deepseek/',
1457 'settings_url' => null,
1458 'stars' => 3,
1459 'enabled' => false
1460 ],
1461 [
1462 'slug' => 'mwai-websearch',
1463 'name' => 'Web Search',
1464 'description' => 'Enhance chatbot responses by pulling context from Google and Tavily, delivering more accurate answers.',
1465 'install_url' => 'https://meowapps.com/products/mwai-websearch/',
1466 'settings_url' => null,
1467 'stars' => 5,
1468 'enabled' => false
1469 ],
1470 [
1471 'slug' => 'mwai-better-links',
1472 'name' => 'Better Links',
1473 'description' => 'Validate internal and external links and map specific terms to custom URLs, ensuring smoother navigation and references.',
1474 'install_url' => 'https://meowapps.com/products/mwai-better-links/',
1475 'settings_url' => null,
1476 'stars' => 3,
1477 'enabled' => false
1478 ],
1479 [
1480 'slug' => 'mwai-woo-basics',
1481 'name' => 'Woo Basics',
1482 'description' => 'Access essential WooCommerce data so your chatbot can understand products, orders, and more for a richer shopping experience.',
1483 'install_url' => 'https://meowapps.com/products/mwai-woo-basics/',
1484 'settings_url' => null,
1485 'stars' => 2,
1486 'enabled' => false
1487 ],
1488 [
1489 'slug' => 'mwai-quick-actions',
1490 'name' => 'Quick Actions',
1491 'description' => 'Enable dynamic quick actions at chat start or during events, helping users find what they need faster.',
1492 'install_url' => 'https://meowapps.com/products/mwai-quick-actions/',
1493 'settings_url' => null,
1494 'stars' => 3,
1495 'enabled' => false
1496 ],
1497 [
1498 'slug' => 'mwai-content-parser',
1499 'name' => 'Content Parser',
1500 'description' => 'Parse complex website content, including ACF fields and page builders, for more precise embeddings and knowledge retrieval.',
1501 'install_url' => 'https://meowapps.com/products/mwai-content-parser/',
1502 'settings_url' => null,
1503 'stars' => 2,
1504 'enabled' => false
1505 ],
1506 [
1507 'slug' => 'mwai-visitor-form',
1508 'name' => 'Visitor Form',
1509 'description' => 'Add a customizable form triggered by specific events in your chatbot to collect key visitor information seamlessly.',
1510 'install_url' => 'https://meowapps.com/products/mwai-visitor-form/',
1511 'settings_url' => null,
1512 'stars' => 2,
1513 'enabled' => false
1514 ],
1515 [
1516 'slug' => 'mwai-dynamic-keys',
1517 'name' => 'Dynamic Keys',
1518 'description' => 'Rotate multiple API keys dynamically for any environment, balancing usage and ensuring smooth performance.',
1519 'install_url' => 'https://meowapps.com/products/mwai-dynamic-keys/',
1520 'settings_url' => null,
1521 'stars' => 1,
1522 'enabled' => false
1523 ],
1524 [
1525 'slug' => 'mwai-user-memory',
1526 'name' => 'User Memory',
1527 'description' => 'Let your chatbot remember details about logged-in users across conversations, for more personal and context-aware replies.',
1528 'install_url' => 'https://meowapps.com/products/mwai-user-memory/',
1529 'settings_url' => null,
1530 'stars' => 3,
1531 'enabled' => false
1532 ],
1533 ] );
1534
1535 // Add-ons mark themselves enabled through the `mwai_addons` filter, but they
1536 // only register that hook inside is_admin(). Over REST (the settings/options
1537 // endpoint, or an options auto-save) is_admin() is false, so none of them would
1538 // report as enabled and the admin UI would show no enabled add-ons. Flag any
1539 // add-on whose plugin is active as a context-independent fallback.
1540 if ( !function_exists( 'is_plugin_active' ) ) {
1541 require_once ABSPATH . 'wp-admin/includes/plugin.php';
1542 }
1543 foreach ( $options['addons'] as &$addon ) {
1544 if ( empty( $addon['enabled'] ) && is_plugin_active( $addon['slug'] . '/' . $addon['slug'] . '.php' ) ) {
1545 $addon['enabled'] = true;
1546 }
1547 }
1548 unset( $addon );
1549
1550 // Populate usage data from ai_usage to ai_models_usage for the frontend
1551 $ai_usage = $this->get_option( 'ai_usage', [] );
1552 $options['ai_models_usage'] = $ai_usage;
1553
1554 // Also include daily usage data
1555 $ai_usage_daily = $this->get_option( 'ai_usage_daily', [] );
1556 $options['ai_models_usage_daily'] = $ai_usage_daily;
1557
1558 $populating = false;
1559 return $options;
1560 }
1561
1562 public function get_all_options( $force = false, $sanitize = false ) {
1563 if ( $force || is_null( $this->options ) ) {
1564 $options = get_option( $this->option_name, [] );
1565 $init_mode = empty( $options );
1566 foreach ( MWAI_OPTIONS as $key => $value ) {
1567 if ( !isset( $options[$key] ) ) {
1568 $options[$key] = $value;
1569 }
1570 }
1571 $options['chatbot_defaults'] = MWAI_CHATBOT_DEFAULT_PARAMS;
1572 $options['default_limits'] = MWAI_LIMITS;
1573
1574 // Force sanitization if custom_languages is not set (migration)
1575 $needs_language_migration = !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] );
1576
1577 if ( $sanitize || $init_mode || $needs_language_migration ) {
1578 $options = $this->sanitize_options( $options );
1579 }
1580 $this->options = $options;
1581 }
1582 $options = $this->populate_dynamic_options( $this->options );
1583 return $options;
1584 }
1585
1586 // Sanitize options when we update the plugin or perform some updates
1587 // if we change the structure of the options.
1588 public function sanitize_options( $options ) {
1589 $needs_update = false;
1590
1591 // Removing old options of options renaming should be done here, as it was done before.
1592 // Check version 2.6.8 for an example.
1593
1594 // Avoid the logs_path to be a PHP file.
1595 if ( isset( $options['logs_path'] ) ) {
1596 $logs_path = $options['logs_path'];
1597 if ( substr( $logs_path, -4 ) !== '.log' ) {
1598 $options['logs_path'] = '';
1599 $needs_update = true;
1600 }
1601 }
1602
1603 // The IDs for the embeddings environments are generated here.
1604 // TODO: We should handle this more gracefully via an option in the Embeddings Settings.
1605 $embeddings_default_exists = false;
1606 if ( isset( $options['embeddings_envs'] ) ) {
1607 foreach ( $options['embeddings_envs'] as &$env ) {
1608 if ( !isset( $env['id'] ) ) {
1609 $env['id'] = $this->get_random_id();
1610 $needs_update = true;
1611 }
1612 if ( $env['id'] === $options['embeddings_default_env'] ) {
1613 $embeddings_default_exists = true;
1614 }
1615 }
1616 }
1617 if ( !$embeddings_default_exists ) {
1618 $options['embeddings_default_env'] = $options['embeddings_envs'][0]['id'] ?? null;
1619 $needs_update = true;
1620 }
1621
1622 // The IDs for the AI environments are generated here.
1623 $allEnvIds = [];
1624 $ai_default_exists = false;
1625 // Allow empty string as valid "None" selection
1626 $ai_default_is_none = isset( $options['ai_default_env'] ) && $options['ai_default_env'] === '';
1627 if ( isset( $options['ai_envs'] ) ) {
1628 foreach ( $options['ai_envs'] as &$env ) {
1629 if ( !isset( $env['id'] ) ) {
1630 $env['id'] = $this->get_random_id();
1631 $needs_update = true;
1632 }
1633 if ( $env['id'] === $options['ai_default_env'] ) {
1634 $ai_default_exists = true;
1635 }
1636 $allEnvIds[] = $env['id'];
1637 }
1638 }
1639 if ( !$ai_default_exists && !$ai_default_is_none ) {
1640 $options['ai_default_env'] = $options['ai_envs'][0]['id'] ?? null;
1641 $needs_update = true;
1642 }
1643
1644 // The IDs for the MCP environments are generated here.
1645 if ( isset( $options['mcp_envs'] ) ) {
1646 foreach ( $options['mcp_envs'] as &$env ) {
1647 if ( !isset( $env['id'] ) ) {
1648 $env['id'] = $this->get_random_id();
1649 $needs_update = true;
1650 }
1651 }
1652 }
1653
1654 // Migration: DALL-E was removed (deprecated by OpenAI). Move users to gpt-image-1.5.
1655 // TODO: Remove after 2027-04 (1 year after the shutdown on 2026-05-12).
1656 if ( isset( $options['ai_images_default_model'] )
1657 && in_array( $options['ai_images_default_model'], [ 'dall-e', 'dall-e-2', 'dall-e-3', 'dall-e-3-hd' ], true ) ) {
1658 $options['ai_images_default_model'] = MWAI_FALLBACK_MODEL_IMAGES;
1659 $needs_update = true;
1660 }
1661
1662 // All the models with an envId that does not exist anymore are removed.
1663 if ( isset( $options['ai_models'] ) ) {
1664 $options['ai_models'] = array_values( array_filter(
1665 $options['ai_models'],
1666 function ( $model ) use ( $allEnvIds, &$needs_update ) {
1667 if ( isset( $model['envId'] ) && !in_array( $model['envId'], $allEnvIds ) ) {
1668 $needs_update = true;
1669 return false;
1670 }
1671 return true;
1672 }
1673 ) );
1674 }
1675
1676 // Migration: limits.creditType 'units' → 'tokens'. The read path in
1677 // premium/statistics.php still accepts both, so this is purely cosmetic
1678 // alignment with the new "Tokens" labels. One-shot per install.
1679 // TODO: Remove after 2026-11.
1680 if ( isset( $options['limits'] ) && is_array( $options['limits'] ) ) {
1681 foreach ( [ 'system', 'users', 'guests' ] as $bucket ) {
1682 if ( isset( $options['limits'][$bucket]['creditType'] )
1683 && $options['limits'][$bucket]['creditType'] === 'units' ) {
1684 $options['limits'][$bucket]['creditType'] = 'tokens';
1685 $needs_update = true;
1686 }
1687 }
1688 }
1689
1690 // Migration: Populate custom_languages if empty for existing installations
1691 if ( !isset( $options['custom_languages'] ) || empty( $options['custom_languages'] ) ) {
1692 $options['custom_languages'] = [
1693 'English (en)',
1694 'German (de)',
1695 'French (fr)',
1696 'Spanish (es)',
1697 'Italian (it)',
1698 'Chinese (zh)',
1699 'Japanese (ja)',
1700 'Portuguese (pt)'
1701 ];
1702 $needs_update = true;
1703 }
1704
1705 if ( $needs_update ) {
1706 ksort( $options );
1707 update_option( $this->option_name, $options, false );
1708 }
1709
1710 return $options;
1711 }
1712
1713 public function update_options( $options ) {
1714 // update_option returns false both when update fails AND when value is unchanged
1715 // We just attempt the update and always return the current options
1716 // The frontend will see if the values actually changed
1717 update_option( $this->option_name, $options, false );
1718 $options = $this->get_all_options( true, true );
1719 return $options;
1720 }
1721
1722 public function update_option( $option, $value ) {
1723 $options = $this->get_all_options( true );
1724 $options[$option] = $value;
1725 return $this->update_options( $options );
1726 }
1727
1728 public function get_option( $option, $default = null ) {
1729 $options = $this->get_all_options();
1730 return $options[$option] ?? $default;
1731 }
1732
1733 public function update_ai_env( $env_id, $option, $value ) {
1734 $options = $this->get_all_options( true );
1735 foreach ( $options['ai_envs'] as &$env ) {
1736 if ( $env['id'] === $env_id ) {
1737 $env[$option] = $value;
1738 break;
1739 }
1740 }
1741 return $this->update_options( $options );
1742 }
1743
1744 public function get_engine_models( $engineType ) {
1745 // This method is called by engines with just a string type
1746 // We need to get the models differently
1747 $options = $this->get_all_options();
1748 $engines = $options['ai_envs'];
1749 $models = [];
1750
1751 // Find all models for this engine type
1752 foreach ( $engines as $engine ) {
1753 if ( $engine['type'] === $engineType ) {
1754 if ( isset( $engine['models'] ) ) {
1755 foreach ( $engine['models'] as $model ) {
1756 $models[] = $model;
1757 }
1758 }
1759 }
1760 }
1761
1762 // Also check custom models
1763 if ( isset( $options['ai_models'] ) ) {
1764 foreach ( $options['ai_models'] as $model ) {
1765 if ( $model['type'] === $engineType ) {
1766 $models[] = $model;
1767 }
1768 }
1769 }
1770
1771 return $models;
1772 }
1773
1774 public function reset_options() {
1775 delete_option( $this->themes_option_name );
1776 delete_option( $this->chatbots_option_name );
1777 delete_option( $this->option_name );
1778 return $this->get_all_options( true );
1779 }
1780 #endregion
1781
1782 #region Cron Tracking
1783 public function track_cron_start( $hook ) {
1784 // Set running transient (expires in 5 minutes as a safety measure)
1785 set_transient( 'mwai_cron_running_' . $hook, true, 300 );
1786 }
1787
1788 public function track_cron_end( $hook, $status = 'success', $error_message = '' ) {
1789 // Remove running transient
1790 delete_transient( 'mwai_cron_running_' . $hook );
1791
1792 // Get existing data
1793 $cron_data = get_transient( 'mwai_cron_last_run' ) ?: [];
1794
1795 // Update this cron's data - use time() for consistency
1796 $cron_data[$hook] = [
1797 'time' => time(),
1798 'status' => $status,
1799 'error' => $error_message
1800 ];
1801
1802 // Store for 7 days
1803 set_transient( 'mwai_cron_last_run', $cron_data, 7 * DAY_IN_SECONDS );
1804 }
1805 #endregion
1806 }
1807