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