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