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