PluginProbe ʕ •ᴥ•ʔ
AI Engine – The Chatbot, AI Framework & MCP for WordPress / 3.7.0
AI Engine – The Chatbot, AI Framework & MCP for WordPress v3.7.0
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 / modules / workspace.php
ai-engine / classes / modules Last commit date
advisor.php 4 months ago chatbot.php 1 week ago discussions.php 1 week ago editor-assistant.php 5 months ago files.php 1 week ago forms-manager.php 4 months ago gdpr.php 5 months ago search.php 5 months ago security.php 1 year ago tasks-examples.php 7 months ago tasks.php 1 week ago wand.php 4 months ago workspace.php 1 week ago
workspace.php
1282 lines
1 <?php
2
3 /**
4 * Workspace: the full-screen, TypingMind-class chat surface inside wp-admin.
5 * Free shell, admins only for now (per-role access comes later); Knowledge,
6 * MCP Servers and Functions inside it are Pro. It runs on the
7 * same chat pipeline as the chatbot (chats/submit + discussions) through the
8 * internal "mwai_workspace" bot, with per-conversation env/model overrides.
9 * Theme, accent and default model are per-user preferences (user meta).
10 */
11 /**
12 * Thrown by the WordPress Tools bridge when the AI wants to run a tool that
13 * needs the user's approval. Not an error: the chat pipeline turns it into an
14 * approval card in the UI and the turn resumes once the user decides.
15 */
16 class Meow_MWAI_ApprovalRequiredException extends Exception {
17 public $tool;
18 public $args;
19
20 public function __construct( $tool, $args = [] ) {
21 parent::__construct( "The tool '{$tool}' requires the user's approval." );
22 $this->tool = (string) $tool;
23 $this->args = is_array( $args ) ? $args : [];
24 }
25 }
26
27 class Meow_MWAI_Modules_Workspace {
28 private $core = null;
29 // "WordPress Tools" state for the current chat request: the categories the
30 // user enabled, and the tool names attached to the query (feedback routing).
31 private $wpToolsCategories = null;
32 private $wpToolNames = [];
33 // Approval state for the current chat request: tool names the user allowed
34 // (once or for the conversation) and tool names they explicitly denied.
35 private $wpToolsAllowed = [];
36 private $wpToolsDenied = [];
37 private $wpToolsTurnKey = '';
38 public const PREFS_META = 'mwai_workspace_prefs';
39 public const BOT_ID = 'mwai_workspace';
40 /**
41 * What this site's Workspace API can do, for clients that ship separately from
42 * the plugin (the mobile app updates through the App Store, sites update when
43 * they feel like it, so the two drift). Bump this whenever a client would need
44 * to know that a site is new enough for something, and never renumber it.
45 *
46 * ADDITIVE ONLY, and this covers two things:
47 *
48 * 1. Routes under mwai/v1/workspace. Renaming or removing one breaks every
49 * phone in the wild until its owner updates the app.
50 * 2. Keys inside the payloads those routes return, build_bootstrap() above in
51 * particular. Adding a key is always safe because clients ignore what they
52 * do not know; removing or renaming one is not. The mobile app decodes that
53 * payload strictly and treats a failed decode as a failed connection, so
54 * dropping a key like modules.embeddings or wp_tools.site_name would not
55 * degrade those devices, it would make their sites unconnectable. A rename
56 * is one quiet line in a plugin diff and total at the other end.
57 *
58 * To retire a key: add its replacement, keep sending the old one, bump this
59 * constant, and only drop the old key once you are willing to tell everyone on
60 * an older app to update.
61 *
62 * 1: pairing, bootstrap, prefs, wp-tools, images, auth-check, pair-check.
63 */
64 public const API_VERSION = 1;
65 /**
66 * MCP tools that must never be bridged into the Workspace conversation.
67 *
68 * mwai_image generates an image and hands back { id, url } as plain text. The image
69 * is stored in the Media Library but never becomes part of the conversation, so the
70 * model cannot see it on the next turn and follow-ups like "make it wider" or "change
71 * the background" have nothing to work from. The Workspace already generates images
72 * through the native image_generation tool (gated by the workspace_image option),
73 * which keeps the result attached to the chat, so bridging this one only added a
74 * second, broken path to the same feature.
75 */
76 private const EXCLUDED_WP_TOOLS = [ 'mwai_image' ];
77
78 public function __construct( $core ) {
79 $this->core = $core;
80 add_action( 'admin_menu', [ $this, 'admin_menu' ] );
81 add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
82 add_action( 'admin_head', [ $this, 'admin_head' ] );
83 // Hidden pages (no menu parent) get no title from WP: set it ourselves.
84 add_filter( 'admin_title', [ $this, 'admin_title' ], 10, 2 );
85 add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
86 add_filter( 'mwai_internal_chatbot', [ $this, 'internal_chatbot' ], 10, 3 );
87 // "This WordPress": expose the site's MCP tools to the AI as plain
88 // functions, so every provider (not just MCP-capable ones) can drive
89 // this WordPress without a network round-trip.
90 add_filter( 'mwai_chatbot_query', [ $this, 'chatbot_query' ], 10, 2 );
91 add_filter( 'mwai_ai_feedback', [ $this, 'ai_feedback' ], 10, 3 );
92 // Multi-step site work (find, read, edit, verify) burns one depth level
93 // per tool round; the default of 5 cuts legitimate tasks short.
94 add_filter( 'mwai_function_call_max_depth', function ( $maxDepth, $query ) {
95 return ( $query->scope ?? '' ) === 'workspace' ? max( 20, (int) $maxDepth ) : $maxDepth;
96 }, 10, 2 );
97 // Mobile companion (QR pairing): authorize Application-Password requests
98 // from admins on AI Engine's nonce-gated endpoints. See authorize_app_password().
99 add_filter( 'mwai_rest_authorized', [ $this, 'authorize_app_password' ], 10, 2 );
100 }
101
102 /**
103 * AI Engine's chat/discussion endpoints gate on a wp_rest nonce (CSRF
104 * protection for the in-admin, cookie-authenticated web app). The iOS
105 * companion authenticates with a WordPress Application Password (HTTP Basic),
106 * which carries no nonce. Authorize those requests when, and only when, they
107 * were authenticated by an Application Password (NOT a cookie) AND resolve to
108 * an admin. Cookie-authenticated browser requests are untouched, so their CSRF
109 * gate stays intact; Basic auth isn't CSRF-exposed, so satisfying the nonce for
110 * a capable user is safe (they can already use the whole WP REST API).
111 *
112 * The check asks WordPress what authenticated THIS request rather than reading
113 * the Authorization header. Servers differ on how Basic credentials reach PHP:
114 * Apache with mod_php consumes the header into PHP_AUTH_USER and never exposes
115 * it as HTTP_AUTHORIZATION, so a header test failed there even though the app
116 * password had authenticated the user perfectly, and every chat message from
117 * the mobile app came back as 403. rest_get_authenticated_app_password() is set
118 * by core itself, so it is right on every server config, and it can never be
119 * true for a browser's cookie session.
120 */
121 public function authorize_app_password( $authorized, $request ) {
122 if ( $authorized ) {
123 return $authorized;
124 }
125 if ( function_exists( 'rest_get_authenticated_app_password' ) && rest_get_authenticated_app_password() ) {
126 return current_user_can( 'manage_options' ) ? true : $authorized;
127 }
128 return $authorized;
129 }
130
131 public function can_access() {
132 return current_user_can( 'manage_options' );
133 }
134
135 private function is_pro() {
136 return (bool) apply_filters( MWAI_PREFIX . '_meowapps_is_registered', false, MWAI_PREFIX );
137 }
138
139 /**
140 * Effective feature availability: the admin-level Settings toggles, with
141 * Knowledge, MCP Servers and Functions additionally requiring Pro (their
142 * underlying modules are Pro anyway).
143 */
144 private function feature_flags() {
145 $pro = $this->is_pro();
146 return [
147 'image' => (bool) $this->core->get_option( 'workspace_image' ),
148 'web_search' => (bool) $this->core->get_option( 'workspace_web_search' ),
149 'wp_tools' => (bool) $this->core->get_option( 'workspace_wp_tools' ),
150 'knowledge' => $pro && (bool) $this->core->get_option( 'workspace_knowledge' ),
151 'mcp' => $pro && (bool) $this->core->get_option( 'workspace_mcp' ),
152 'functions' => $pro && (bool) $this->core->get_option( 'workspace_functions' ),
153 ];
154 }
155
156 private function is_workspace_page() {
157 return is_admin() && isset( $_GET['page'] ) && $_GET['page'] === 'mwai_workspace';
158 }
159
160 public function admin_menu() {
161 // No menu entry: the Workspace is reached from the Admin Bar and the
162 // AI Engine header. An empty parent registers the page without listing it,
163 // so admin.php?page=mwai_workspace keeps working.
164 add_submenu_page(
165 '',
166 'AI Workspace',
167 'Workspace',
168 'manage_options',
169 'mwai_workspace',
170 [ $this, 'render_page' ]
171 );
172 }
173
174 public function admin_title( $admin_title, $title ) {
175 if ( $this->is_workspace_page() ) {
176 return 'AI Workspace' . $admin_title;
177 }
178 return $admin_title;
179 }
180
181 public function render_page() {
182 if ( !$this->can_access() ) {
183 wp_die( __( 'Sorry, you are not allowed to access this page.', 'ai-engine' ), 403 );
184 }
185 echo '<div id="mwai-workspace" data-theme="dark"></div>';
186 }
187
188 /**
189 * The Workspace takes the whole viewport: hide the WP admin chrome on its
190 * page only, like the Site Editor does. The div itself is position:fixed.
191 */
192 public function admin_head() {
193 if ( !$this->is_workspace_page() ) {
194 return;
195 }
196 echo '<style>
197 #adminmenumain, #wpadminbar, #wpfooter, #screen-meta, #screen-meta-links { display: none !important; }
198 html.wp-toolbar { padding-top: 0 !important; }
199 #wpcontent, #wpbody-content { margin-left: 0 !important; padding: 0 !important; }
200 #wpbody-content .notice, #wpbody-content .updated, #wpbody-content .error { display: none !important; }
201 </style>';
202 }
203
204 public function admin_enqueue_scripts() {
205 if ( !$this->is_workspace_page() || !$this->can_access() ) {
206 return;
207 }
208
209 $physical_file = MWAI_PATH . '/app/workspace.js';
210 $cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION;
211
212 wp_register_script(
213 'mwai_workspace',
214 MWAI_URL . 'app/workspace.js',
215 [ 'wp-element', 'wp-i18n' ],
216 $cache_buster
217 );
218 wp_enqueue_script( 'mwai_workspace' );
219
220 // Syntax highlighting is always on in the Workspace, independently of the
221 // chatbot's syntax_highlight option. The bundled dark theme suits the
222 // Workspace's code blocks, which stay dark in both themes.
223 wp_enqueue_script( 'mwai_highlight' );
224 wp_enqueue_style( 'mwai_workspace_highlight', MWAI_URL . 'vendor/highlightjs/stackoverflow-dark.min.css', [], '11.7' );
225
226 wp_enqueue_style( 'mwai_workspace', MWAI_URL . 'app/workspace.css', [], $cache_buster );
227 // The Workspace type system (self-hosting these is on the roadmap).
228 wp_enqueue_style(
229 'mwai_workspace_fonts',
230 'https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@1,6..72,300;1,6..72,400&family=Spline+Sans+Mono:wght@400;500&display=swap',
231 [],
232 null
233 );
234
235 wp_localize_script( 'mwai_workspace', 'mwai_workspace', array_merge( $this->build_bootstrap(), [
236 // Page-only bits: the web app authenticates with the cookie + nonce, the
237 // mobile companion with an Application Password (no nonce, no session).
238 'rest_nonce' => wp_create_nonce( 'wp_rest' ),
239 'session' => $this->core->get_session_id(),
240 'debug_mode' => (bool) ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ) ),
241 ] ) );
242 }
243
244 /**
245 * Everything a Workspace client needs to start: the model catalog, the
246 * per-user preferences, the feature flags and the tool catalog. Shared by the
247 * admin page (localized at load) and the mobile companion (one REST call).
248 */
249 private function build_bootstrap() {
250 $user = wp_get_current_user();
251 return [
252 // Version handshake. A client that needs something this site cannot do
253 // should be able to say "update AI Engine" instead of guessing at a 404
254 // from a route that does not exist yet. The plugin version is for humans
255 // and bug reports; api_version is what clients compare against.
256 'plugin_version' => MWAI_VERSION,
257 'api_version' => self::API_VERSION,
258 'rest_url' => untrailingslashit( get_rest_url() ),
259 'admin_url' => admin_url(),
260 'api_url' => untrailingslashit( get_rest_url( null, 'mwai/v1' ) ),
261 'plugin_url' => untrailingslashit( MWAI_URL ),
262 'site_name' => get_bloginfo( 'name' ),
263 'site_url' => untrailingslashit( home_url() ),
264 'stream' => (bool) $this->core->get_option( 'ai_streaming' ),
265 'user' => [
266 'display_name' => $user->display_name,
267 'role' => !empty( $user->roles ) ? $user->roles[0] : '',
268 ],
269 'prefs' => $this->get_prefs(),
270 'envs' => $this->build_envs(),
271 'embeddings_envs' => $this->build_embeddings_envs(),
272 'mcp_envs' => $this->build_mcp_envs(),
273 'functions' => $this->build_functions(),
274 'wp_tools' => $this->build_wp_tools(),
275 'modules' => [
276 'embeddings' => (bool) $this->core->get_option( 'module_embeddings' ),
277 'orchestration' => (bool) $this->core->get_option( 'module_orchestration' ),
278 ],
279 // Admin-level feature availability (Settings > Workspace + Pro).
280 'features' => $this->feature_flags(),
281 // Features that exist but need Pro: shown as tasteful locked rows.
282 'locked_features' => $this->is_pro() ? [] : [ 'knowledge', 'mcp', 'functions' ],
283 'is_pro' => $this->is_pro(),
284 'upsell_url' => 'https://meowapps.com/ai-engine/',
285 'settings_url' => admin_url( 'admin.php?page=mwai_settings' ),
286 ];
287 }
288
289 private function build_embeddings_envs() {
290 if ( !$this->core->get_option( 'module_embeddings' ) ) {
291 return [];
292 }
293 $out = [];
294 foreach ( (array) $this->core->get_option( 'embeddings_envs' ) as $env ) {
295 if ( !empty( $env['id'] ) ) {
296 $out[] = [
297 'id' => $env['id'],
298 'name' => $env['name'] ?? $env['id'],
299 'type' => $env['type'] ?? '',
300 ];
301 }
302 }
303 return $out;
304 }
305
306 private function build_mcp_envs() {
307 if ( !$this->core->get_option( 'module_orchestration' ) ) {
308 return [];
309 }
310 $out = [];
311 foreach ( (array) $this->core->get_option( 'mcp_envs' ) as $env ) {
312 if ( !empty( $env['id'] ) ) {
313 $out[] = [ 'id' => $env['id'], 'name' => $env['name'] ?? $env['id'] ];
314 }
315 }
316 return $out;
317 }
318
319 private function build_functions() {
320 $out = [];
321 $functions = apply_filters( 'mwai_functions_list', [] );
322 foreach ( (array) $functions as $f ) {
323 $f = is_object( $f ) ? (array) $f : $f;
324 if ( !is_array( $f ) || empty( $f['id'] ) || empty( $f['type'] ) ) {
325 continue;
326 }
327 // Editor Assistant functions only make sense inside the block editor.
328 if ( $f['type'] === 'editor-assistant' ) {
329 continue;
330 }
331 $out[] = [
332 'id' => (string) $f['id'],
333 'name' => $f['name'] ?? (string) $f['id'],
334 'desc' => $f['description'] ?? '',
335 'type' => $f['type'],
336 ];
337 }
338 return $out;
339 }
340
341 /**
342 * The env + model catalog for the picker: every AI environment with its
343 * chat-capable models (same filtering the smoke gate uses).
344 */
345 private function build_envs() {
346 $out = [];
347 $envs = $this->core->get_option( 'ai_envs' );
348 if ( !is_array( $envs ) ) {
349 return $out;
350 }
351 foreach ( $envs as $env ) {
352 try {
353 $engine = Meow_MWAI_Engines_Factory::get( $this->core, $env['id'] );
354 if ( !$engine ) {
355 continue;
356 }
357 $models = $engine->get_models();
358 $chatModels = [];
359 foreach ( (array) $models as $m ) {
360 $tags = $m['tags'] ?? [];
361 $features = $m['features'] ?? [];
362 if ( !in_array( 'chat', $tags, true ) || in_array( 'deprecated', $tags, true ) ) {
363 continue;
364 }
365 if ( !empty( $features ) && !in_array( 'completion', $features, true ) ) {
366 continue;
367 }
368 if ( preg_match( '/guard|moderat|safety|embed|whisper|tts|rerank/i', $m['model'] ) ) {
369 continue;
370 }
371 $chatModels[] = [
372 'model' => $m['model'],
373 'name' => $m['name'] ?? $m['model'],
374 // Capability hints for the UI (files/functions warnings).
375 'tags' => array_values( array_intersect( $tags, [ 'vision', 'files', 'functions', 'reasoning', 'no-temperature' ] ) ),
376 // Whether the model can produce images in a chat turn: OpenAI via
377 // the image_generation tool, Gemini Flash Image natively.
378 'image' => in_array( 'image_generation', (array) ( $m['tools'] ?? [] ), true ) ||
379 in_array( 'image-generation', (array) $features, true ),
380 // Whether the provider runs web search server-side for this model. Sent as
381 // a resolved boolean like 'image' above: the raw tools array never reaches
382 // the UI, so checking it client-side always came back false.
383 'web_search' => in_array( 'web_search', (array) ( $m['tools'] ?? [] ), true ),
384 ];
385 }
386 if ( !empty( $chatModels ) ) {
387 $out[] = [
388 'id' => $env['id'],
389 'name' => $env['name'],
390 'type' => $env['type'],
391 'models' => $chatModels,
392 ];
393 }
394 }
395 catch ( Exception $e ) {
396 // An unusable env (bad key, unknown type) simply doesn't appear.
397 continue;
398 }
399 }
400 return $out;
401 }
402
403 /**
404 * The internal bot behind the Workspace: based on the default chatbot so all
405 * pipeline expectations hold, with a neutral personality. Client-sent envId
406 * and model override it per conversation.
407 */
408 public function internal_chatbot( $chatbot, $botId, $params ) {
409 if ( $botId !== self::BOT_ID || !empty( $chatbot ) ) {
410 return $chatbot;
411 }
412 if ( !is_user_logged_in() || !$this->can_access() ) {
413 return $chatbot;
414 }
415 $instructions = 'You are a capable, direct assistant. Format your answers in Markdown.';
416 // "This WordPress" mode: remember the requested categories for this
417 // request (chatbot_query attaches the tools) and tell the AI where it is.
418 $wpTools = $params['wpTools'] ?? null;
419 if ( is_array( $wpTools ) && !empty( $wpTools ) && $this->core->get_option( 'workspace_wp_tools' ) ) {
420 $this->wpToolsCategories = array_values( array_filter( array_map(
421 'sanitize_text_field',
422 array_slice( $wpTools, 0, 20 )
423 ) ) );
424 // The user's approval decisions travel with the request (per chat).
425 foreach ( [ 'wpToolsAllowed', 'wpToolsDenied' ] as $key ) {
426 if ( isset( $params[$key] ) && is_array( $params[$key] ) ) {
427 $this->{$key} = array_values( array_filter( array_map(
428 'sanitize_text_field',
429 array_slice( $params[$key], 0, 50 )
430 ) ) );
431 }
432 }
433 // Identifies the current turn for the write-tool result cache.
434 $this->wpToolsTurnKey = md5( ( $params['chatId'] ?? '' ) . '|' . ( $params['newMessage'] ?? '' ) );
435 $instructions .= "\n\nYou have direct tool access to this WordPress site: " .
436 get_bloginfo( 'name' ) . ' (' . home_url() . '). Use the available tools to read, ' .
437 'analyze and modify the site when the user asks. Before a destructive or ' .
438 'hard-to-undo change (deleting content, changing options), state what you are ' .
439 'about to do and ask for confirmation first.';
440 }
441 $base = $this->core->get_chatbot( 'default' );
442 $base = is_array( $base ) ? $base : [];
443 return array_merge( $base, [
444 'botId' => self::BOT_ID,
445 'name' => 'Workspace',
446 'scope' => 'workspace',
447 'instructions' => $instructions,
448 'startSentence' => '',
449 'localMemory' => false,
450 'window' => false,
451 'fullscreen' => false,
452 'textInputMaxLength' => 32000,
453 'maxMessages' => 100,
454 'contentAware' => false,
455 'imageUpload' => true,
456 'fileUpload' => true,
457 'multiUpload' => true,
458 // The default bot's tools/knowledge must NOT leak into the Workspace:
459 // its features are per-conversation, so only client-sent selections
460 // apply (they merge over this config in chat_submit).
461 'functions' => [],
462 'mcpServers' => [],
463 'tools' => [],
464 'embeddingsEnvId' => '',
465 'embeddingsIndex' => '',
466 'embeddingsNamespace' => '',
467 ] );
468 }
469
470 #region This WordPress (MCP tools as functions)
471
472 private function get_mcp_tools() {
473 static $tools = null;
474 if ( $tools === null ) {
475 $tools = $this->core->get_option( 'module_mcp' ) ? apply_filters( 'mwai_mcp_tools', [] ) : [];
476 $tools = is_array( $tools ) ? $tools : [];
477 }
478 return $tools;
479 }
480
481 /**
482 * Category catalog for the composer's "This WordPress" popover.
483 */
484 private function build_wp_tools() {
485 $categories = [];
486 foreach ( $this->get_mcp_tools() as $tool ) {
487 if ( empty( $tool['name'] ) ) {
488 continue;
489 }
490 // Keep the popover honest: never advertise (or count) a tool the query builder
491 // refuses to attach.
492 if ( in_array( $tool['name'], self::EXCLUDED_WP_TOOLS, true ) ) {
493 continue;
494 }
495 // Same default as the MCP server's tools listing.
496 $cat = !empty( $tool['category'] ) ? (string) $tool['category'] : 'AI Engine (Core)';
497 if ( !isset( $categories[$cat] ) ) {
498 $categories[$cat] = [ 'name' => $cat, 'count' => 0, 'tools' => [] ];
499 }
500 $categories[$cat]['count']++;
501 $categories[$cat]['tools'][] = (string) $tool['name'];
502 }
503 ksort( $categories );
504 return [
505 'enabled' => (bool) $this->core->get_option( 'module_mcp' ) && (bool) $this->core->get_option( 'workspace_wp_tools' ),
506 'site_name' => get_bloginfo( 'name' ),
507 'categories' => array_values( $categories ),
508 ];
509 }
510
511 public function chatbot_query( $query, $params ) {
512 // Enforce the effective feature availability (Settings > Workspace + Pro).
513 // The Workspace UI hides disabled features; this is the server-side gate.
514 if ( ( $query->scope ?? '' ) === 'workspace' ) {
515 $flags = $this->feature_flags();
516 if ( !$flags['mcp'] && isset( $query->mcpServers ) ) {
517 $query->mcpServers = [];
518 }
519 if ( !$flags['functions'] && !empty( $query->functions ) ) {
520 $query->set_functions( [] );
521 }
522 if ( !$flags['image'] && !empty( $query->tools ) ) {
523 $query->tools = array_values( array_diff( $query->tools, [ 'image_generation' ] ) );
524 }
525 if ( !$flags['web_search'] && !empty( $query->tools ) ) {
526 $query->tools = array_values( array_diff( $query->tools, [ 'web_search' ] ) );
527 }
528 if ( !$flags['knowledge'] && isset( $query->embeddingsEnvId ) ) {
529 $query->embeddingsEnvId = null;
530 }
531 }
532 if ( $this->wpToolsCategories === null || !$this->can_access() ) {
533 return $query;
534 }
535 $existing = [];
536 foreach ( (array) $query->functions as $fn ) {
537 $existing[$fn->name] = true;
538 }
539 foreach ( $this->get_mcp_tools() as $tool ) {
540 $name = $tool['name'] ?? '';
541 $cat = !empty( $tool['category'] ) ? (string) $tool['category'] : 'AI Engine (Core)';
542 if ( empty( $name ) || isset( $existing[$name] ) || !in_array( $cat, $this->wpToolsCategories, true ) ) {
543 continue;
544 }
545 if ( in_array( $name, self::EXCLUDED_WP_TOOLS, true ) ) {
546 continue;
547 }
548 if ( !preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $name ) ) {
549 continue;
550 }
551 $query->add_function( Meow_MWAI_Query_Function::from_raw_schema(
552 $name,
553 (string) ( $tool['description'] ?? '' ),
554 is_array( $tool['inputSchema'] ?? null ) ? $tool['inputSchema'] : null,
555 'mcp-bridge'
556 ) );
557 $existing[$name] = true;
558 $this->wpToolNames[$name] = true;
559 }
560 return $query;
561 }
562
563 /**
564 * A tool needs the user's approval when it can change the site. Core tools
565 * carry MCP annotations (readOnlyHint); tools without annotations are judged
566 * by their name, and unknown verbs count as writes: safe by default.
567 */
568 private function tool_requires_approval( $name ) {
569 foreach ( $this->get_mcp_tools() as $tool ) {
570 if ( ( $tool['name'] ?? '' ) === $name ) {
571 $annotations = $tool['annotations'] ?? null;
572 if ( is_array( $annotations ) ) {
573 if ( array_key_exists( 'readOnlyHint', $annotations ) ) {
574 return empty( $annotations['readOnlyHint'] );
575 }
576 // Annotated, but silent about writing: the provider knows about
577 // annotations and still did not claim to be read-only, so ask. This
578 // covers Code Engine functions, whose name is whatever PHP snippet
579 // the user wrote and must never be trusted by the heuristic below.
580 return true;
581 }
582 break;
583 }
584 }
585 // No annotations at all: fall back to the name. A destructive verb wins
586 // over a read-only one, otherwise a tool like
587 // "dbclnr_run_custom_query_delete" reads as safe because it contains
588 // "query" and would wipe rows with no dialog.
589 if ( preg_match(
590 '/(^|_)(delete|remove|drop|truncate|purge|destroy|reset|flush|clear|write|upload|install|uninstall|activate|deactivate|execute|run|import|restore|optimize|repair)(_|$)/i',
591 $name
592 ) ) {
593 return true;
594 }
595 return !preg_match(
596 '/(^|_)(get|list|count|search|query|check|compare|suggest|rank|preview|stats|statistics|status|profile|pulse|digest|mix|insights|ping)(_|$)/i',
597 $name
598 );
599 }
600
601 public function ai_feedback( $value, $needFeedback, $reply ) {
602 if ( $value !== null ) {
603 return $value;
604 }
605 $name = $needFeedback['name'] ?? null;
606 // Only tools this request explicitly attached are executable, and only
607 // for the (admin) user driving the Workspace.
608 if ( !$name || empty( $this->wpToolNames[$name] ) || !$this->can_access() ) {
609 return $value;
610 }
611 $args = $needFeedback['arguments'] ?? [];
612 if ( is_string( $args ) ) {
613 $decoded = json_decode( $args, true );
614 $args = is_array( $decoded ) ? $decoded : [];
615 }
616
617 // Approval gate: anything that writes to the site pauses the turn and
618 // asks the user in the UI, unless they already allowed this tool for the
619 // conversation (or this run). A denial is handed to the AI as the result.
620 if ( in_array( $name, $this->wpToolsDenied, true ) ) {
621 return "The user DENIED running '{$name}'. Do not retry it. Briefly acknowledge this and ask the user how they would like to proceed instead.";
622 }
623 $needsApproval = $this->tool_requires_approval( $name );
624 if ( !in_array( $name, $this->wpToolsAllowed, true ) && $needsApproval ) {
625 throw new Meow_MWAI_ApprovalRequiredException( $name, $args );
626 }
627
628 // Approving a call re-runs the whole turn, so a write that already ran in
629 // a previous pass of the SAME turn would execute twice (duplicate post,
630 // double delete). Identical calls within one turn return the first result
631 // instead. Keyed by chatId + user message, short-lived, writes only:
632 // reads stay fresh and a new user message never hits the cache.
633 $cacheKey = '';
634 if ( $needsApproval && !empty( $this->wpToolsTurnKey ) ) {
635 $cacheKey = 'mwai_ws_tool_' . md5( $this->wpToolsTurnKey . '|' . $name . '|' . wp_json_encode( $args ) );
636 $cached = get_transient( $cacheKey );
637 if ( is_array( $cached ) && array_key_exists( 'v', $cached ) ) {
638 return $cached['v'];
639 }
640 }
641
642 $out = null;
643 $failed = false;
644 try {
645 // Handlers type the JSON-RPC id as ?int; the provider tool-call id is a
646 // string ("call_..."), so pass a neutral int instead.
647 //
648 // The fifth argument is the MCP server instance, and there is none here:
649 // the Workspace runs tools directly rather than over the MCP endpoint. It
650 // is still passed, as null, so the filter has one arity everywhere. Firing
651 // four arguments here while labs/mcp.php fires five meant a third-party
652 // handler registered with add_filter( ..., 10, 5 ) worked over MCP and then
653 // died with an ArgumentCountError the moment the same tool ran in the
654 // Workspace. Handlers that want the instance must tolerate null.
655 $result = apply_filters( 'mwai_mcp_callback', null, $name, $args, 0, null );
656 if ( $result === null ) {
657 $out = "The tool '{$name}' is not available on this site.";
658 $failed = true;
659 }
660 else {
661 // Some handlers return a full JSON-RPC envelope: unwrap it.
662 if ( is_array( $result ) && isset( $result['jsonrpc'] ) ) {
663 if ( isset( $result['error'] ) ) {
664 $out = 'Error: ' . ( $result['error']['message'] ?? 'The tool failed.' );
665 $failed = true;
666 }
667 $result = $result['result'] ?? null;
668 }
669 if ( $out === null ) {
670 // MCP content envelope ({ content: [{type,text}], isError }): flatten
671 // the text parts, which is what the model actually needs.
672 if ( is_array( $result ) && isset( $result['content'] ) && is_array( $result['content'] ) ) {
673 $texts = [];
674 foreach ( $result['content'] as $part ) {
675 if ( isset( $part['text'] ) ) {
676 $texts[] = $part['text'];
677 }
678 }
679 $flat = implode( "\n", $texts );
680 $failed = !empty( $result['isError'] );
681 $out = $failed ? 'Error: ' . $flat : $flat;
682 }
683 else {
684 $out = $result;
685 }
686 }
687 }
688 }
689 catch ( Throwable $e ) {
690 $out = "Error: the tool '{$name}' crashed (" . $e->getMessage() . '). The other tools should still work.';
691 $failed = true;
692 }
693 // Only successful write results are cached (see the note above); failures
694 // may be retried fresh on the next pass.
695 if ( $cacheKey !== '' && !$failed ) {
696 set_transient( $cacheKey, [ 'v' => $out ], 5 * MINUTE_IN_SECONDS );
697 }
698 return $out;
699 }
700
701 #endregion
702
703 #region Preferences (per WordPress user)
704
705 public function rest_api_init() {
706 register_rest_route( 'mwai/v1', '/workspace/prefs', [
707 'methods' => [ 'GET', 'POST' ],
708 'callback' => [ $this, 'rest_prefs' ],
709 'permission_callback' => [ $this, 'can_access' ],
710 ] );
711 // The full tool catalog only exists in REST context: several providers
712 // (AI Engine core among them) register their mwai_mcp_tools filter on
713 // rest_api_init, so the admin page's localized copy misses them.
714 register_rest_route( 'mwai/v1', '/workspace/wp-tools', [
715 'methods' => [ 'GET', 'POST' ],
716 'callback' => function () {
717 return new WP_REST_Response( [ 'success' => true, 'wp_tools' => $this->build_wp_tools() ], 200 );
718 },
719 'permission_callback' => [ $this, 'can_access' ],
720 ] );
721 // One call for everything a client needs at launch (model catalog, prefs,
722 // feature flags, tool catalog). The mobile companion uses this instead of
723 // assembling it from settings/options + prefs + wp-tools.
724 register_rest_route( 'mwai/v1', '/workspace/bootstrap', [
725 'methods' => 'GET',
726 'callback' => function () {
727 return new WP_REST_Response( array_merge( [ 'success' => true ], $this->build_bootstrap() ), 200 );
728 },
729 'permission_callback' => [ $this, 'can_access' ],
730 ] );
731 register_rest_route( 'mwai/v1', '/workspace/image-info', [
732 'methods' => 'POST',
733 'callback' => [ $this, 'rest_image_info' ],
734 'permission_callback' => [ $this, 'can_access' ],
735 ] );
736 register_rest_route( 'mwai/v1', '/workspace/image-persist', [
737 'methods' => 'POST',
738 'callback' => [ $this, 'rest_image_persist' ],
739 'permission_callback' => [ $this, 'can_access' ],
740 ] );
741
742 // Mobile pairing (QR connect).
743 register_rest_route( 'mwai/v1', '/workspace/pair-token', [
744 'methods' => 'POST',
745 'callback' => [ $this, 'rest_pair_token' ],
746 'permission_callback' => [ $this, 'can_access' ],
747 ] );
748 // Public on purpose: gated entirely by the one-time, short-lived token.
749 register_rest_route( 'mwai/v1', '/workspace/pair', [
750 'methods' => 'POST',
751 'callback' => [ $this, 'rest_pair' ],
752 'permission_callback' => '__return_true',
753 ] );
754 // Public on purpose: it only describes the caller's own request. See rest_auth_check().
755 register_rest_route( 'mwai/v1', '/workspace/auth-check', [
756 'methods' => 'GET',
757 'callback' => [ $this, 'rest_auth_check' ],
758 'permission_callback' => '__return_true',
759 ] );
760 register_rest_route( 'mwai/v1', '/workspace/pair-check', [
761 'methods' => 'GET',
762 'callback' => [ $this, 'rest_pair_check' ],
763 'permission_callback' => [ $this, 'can_access' ],
764 ] );
765 register_rest_route( 'mwai/v1', '/workspace/devices', [
766 'methods' => 'GET',
767 'callback' => [ $this, 'rest_devices' ],
768 'permission_callback' => [ $this, 'can_access' ],
769 ] );
770 register_rest_route( 'mwai/v1', '/workspace/devices/revoke', [
771 'methods' => 'POST',
772 'callback' => [ $this, 'rest_device_revoke' ],
773 'permission_callback' => [ $this, 'can_access' ],
774 ] );
775 }
776
777 #region Mobile pairing (QR connect)
778
779 // Application Passwords named with this prefix are "paired devices".
780 public const PAIR_APP_PREFIX = 'Workspace by AI Engine';
781 public const PAIR_TOKEN_TTL = 300; // 5 minutes.
782 /**
783 * Everything that can stop a phone from connecting (a server that drops the
784 * Authorization header, a security plugin filtering the REST API, a revoked
785 * device) is explained here. The messages below name the problem and link out
786 * rather than trying to teach .htaccess in a banner, the same way the MCP
787 * self-test points at the same page for its own failures.
788 *
789 * The fragment matters: that page opens on MCP and OAuth, so a phone user
790 * landing at the top would not recognise their problem. If the anchor is ever
791 * dropped from the page the link still works, it just lands higher up.
792 */
793 public const DOC_MOBILE_URL = 'https://meowapps.com/fix-mcp-wordpress-connection/#workspace-mobile';
794
795 private function pairing_available( $user = null ) {
796 if ( !class_exists( 'WP_Application_Passwords' ) || !function_exists( 'wp_is_application_passwords_available' ) ) {
797 return false;
798 }
799 if ( !wp_is_application_passwords_available() ) {
800 return false;
801 }
802 if ( $user && !wp_is_application_passwords_available_for_user( $user ) ) {
803 return false;
804 }
805 return true;
806 }
807
808 private function pairing_unavailable_reason() {
809 if ( !function_exists( 'wp_is_application_passwords_available' ) || !wp_is_application_passwords_available() ) {
810 return 'Application Passwords are disabled on this site (they require HTTPS). Enable HTTPS or Application Passwords to connect a mobile app.';
811 }
812 return 'Application Passwords are not available for this account.';
813 }
814
815 /**
816 * Admin action: mint a short-lived, single-use pairing token and return the
817 * QR payload. The token's hash (not the token) is stored server-side.
818 */
819 public function rest_pair_token( $request ) {
820 if ( !$this->pairing_available( wp_get_current_user() ) ) {
821 return new WP_REST_Response( [ 'success' => false, 'message' => $this->pairing_unavailable_reason() ], 200 );
822 }
823 try {
824 $token = bin2hex( random_bytes( 24 ) ); // 48 hex chars: infeasible to guess in 5 min.
825 }
826 catch ( Exception $e ) {
827 return new WP_REST_Response( [ 'success' => false, 'message' => 'Could not generate a secure token.' ], 500 );
828 }
829 set_transient(
830 'mwai_pair_' . hash( 'sha256', $token ),
831 [ 'user_id' => get_current_user_id(), 't' => time() ],
832 self::PAIR_TOKEN_TTL
833 );
834 return new WP_REST_Response( [
835 'success' => true,
836 'payload' => [ 'v' => 1, 'url' => untrailingslashit( home_url() ), 'pair' => $token ],
837 'expires_in' => self::PAIR_TOKEN_TTL,
838 'site_name' => get_bloginfo( 'name' ),
839 ], 200 );
840 }
841
842 /**
843 * Public: the mobile app exchanges a valid pairing token for a WordPress
844 * Application Password (created for the admin who generated the token).
845 */
846 public function rest_pair( $request ) {
847 // Defense in depth (tokens are already unguessable): rate-limit per IP.
848 $ip = method_exists( $this->core, 'get_ip_address' ) ? $this->core->get_ip_address() : ( $_SERVER['REMOTE_ADDR'] ?? '' );
849 $rlKey = 'mwai_pair_rl_' . md5( (string) $ip );
850 $attempts = (int) get_transient( $rlKey );
851 if ( $attempts >= 20 ) {
852 return new WP_REST_Response( [ 'success' => false, 'message' => 'Too many attempts. Please wait a minute.' ], 429 );
853 }
854 set_transient( $rlKey, $attempts + 1, MINUTE_IN_SECONDS );
855
856 $params = $request->get_json_params();
857 $token = isset( $params['token'] ) ? (string) $params['token'] : '';
858 $device = isset( $params['device'] ) ? mb_substr( sanitize_text_field( $params['device'] ), 0, 60 ) : '';
859 if ( $device === '' ) {
860 $device = 'Mobile device';
861 }
862 if ( $token === '' || !ctype_xdigit( $token ) || strlen( $token ) > 128 ) {
863 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid pairing code.' ], 400 );
864 }
865 $tKey = 'mwai_pair_' . hash( 'sha256', $token );
866 $data = get_transient( $tKey );
867 if ( !is_array( $data ) || empty( $data['user_id'] ) ) {
868 return new WP_REST_Response( [ 'success' => false, 'message' => 'This pairing code has expired. Generate a new one.' ], 400 );
869 }
870 delete_transient( $tKey ); // single use.
871
872 $user = get_user_by( 'id', (int) $data['user_id'] );
873 if ( !$user || !user_can( $user, 'manage_options' ) ) {
874 return new WP_REST_Response( [ 'success' => false, 'message' => 'That account can no longer connect a mobile app.' ], 403 );
875 }
876 if ( !$this->pairing_available( $user ) ) {
877 return new WP_REST_Response( [ 'success' => false, 'message' => $this->pairing_unavailable_reason() ], 400 );
878 }
879
880 $created = WP_Application_Passwords::create_new_application_password(
881 $user->ID,
882 [ 'name' => self::PAIR_APP_PREFIX . ' - ' . $device ]
883 );
884 if ( is_wp_error( $created ) ) {
885 return new WP_REST_Response( [ 'success' => false, 'message' => $created->get_error_message() ], 500 );
886 }
887 list( $password, $item ) = $created;
888
889 return new WP_REST_Response( [
890 'success' => true,
891 'url' => untrailingslashit( home_url() ),
892 'rest_url' => untrailingslashit( get_rest_url() ),
893 'site_name' => get_bloginfo( 'name' ),
894 'user' => $user->user_login,
895 'app_password' => $password, // WordPress reveals this exactly once.
896 'uuid' => $item['uuid'] ?? null,
897 ], 200 );
898 }
899
900 /**
901 * What did this server actually do with the credentials the caller sent?
902 *
903 * A device can pair perfectly and then get 401 on every single call, and from
904 * inside WordPress that is invisible: the two usual causes (the server never
905 * forwards the Authorization header to PHP, or a security plugin refuses
906 * Application Password authentication) both look like "not logged in". The
907 * mobile app calls this after an unexpected 401 so it can name the real
908 * problem instead of telling the user to reconnect forever.
909 *
910 * Public on purpose: every field describes the caller's own request, so this
911 * tells an attacker exactly what a 401 from any other endpoint already does.
912 */
913 public function rest_auth_check( $request ) {
914 // It answers "were those credentials good?" with a 200, which is no more
915 // than any authenticated route already reveals through its 401. Still, the
916 // cheerful phrasing is the kind of thing a reviewer stops on, so it gets the
917 // same per-IP limiter as the pairing route. A client only ever calls this
918 // once, after a failure.
919 $ip = method_exists( $this->core, 'get_ip_address' ) ? $this->core->get_ip_address() : ( $_SERVER['REMOTE_ADDR'] ?? '' );
920 $rlKey = 'mwai_authchk_rl_' . md5( (string) $ip );
921 $attempts = (int) get_transient( $rlKey );
922 if ( $attempts >= 30 ) {
923 return new WP_REST_Response( [ 'success' => false, 'message' => 'Too many attempts. Please wait a minute.' ], 429 );
924 }
925 set_transient( $rlKey, $attempts + 1, MINUTE_IN_SECONDS );
926
927 $header = $request instanceof WP_REST_Request ? (string) $request->get_header( 'authorization' ) : '';
928 // Either location counts: mod_php consumes the header into PHP_AUTH_* and
929 // leaves get_header() empty, which is still a server that forwards it.
930 $sawHeader = $header !== '' || !empty( $_SERVER['PHP_AUTH_USER'] );
931 $userId = get_current_user_id();
932 $viaAppPassword = function_exists( 'rest_get_authenticated_app_password' )
933 && (bool) rest_get_authenticated_app_password();
934
935 if ( !$sawHeader ) {
936 // Careful with this wording: "the client sent nothing" and "the client
937 // sent credentials and this server stripped them" arrive here completely
938 // identically, so naming the server as the culprit would send users to
939 // their host for a bug that may be in the client. pair-check is the route
940 // that may be confident about it: it probes with a header it knows it sent.
941 $reason = 'no_authorization_header';
942 // The URL is inlined in the text as well as returned separately: a client
943 // that only renders `message` still gives the user somewhere to go.
944 $message = 'No credentials reached WordPress. Either the app sent none, or this server does not forward the Authorization header to PHP, which is a common host setting. ' . self::DOC_MOBILE_URL;
945 }
946 elseif ( !$userId ) {
947 $reason = 'credentials_rejected';
948 $message = 'WordPress received the credentials but refused them. The app password may have been revoked, or a security plugin may be blocking Application Passwords. ' . self::DOC_MOBILE_URL;
949 }
950 elseif ( !current_user_can( 'manage_options' ) ) {
951 $reason = 'not_admin';
952 $message = 'That account is not an administrator, and the Workspace is available to administrators only.';
953 }
954 else {
955 $reason = 'ok';
956 $message = 'Authentication is working.';
957 }
958
959 return new WP_REST_Response( [
960 'success' => true,
961 'reason' => $reason,
962 'message' => $message,
963 'authorization_header' => $sawHeader,
964 'authenticated' => $userId > 0,
965 'app_password' => $viaAppPassword,
966 'can_use_workspace' => $userId > 0 && current_user_can( 'manage_options' ),
967 'app_passwords_available' => $this->pairing_available( $userId ? wp_get_current_user() : null ),
968 'ssl' => is_ssl(),
969 'doc_url' => self::DOC_MOBILE_URL,
970 ], 200 );
971 }
972
973 /**
974 * Admin-side pre-flight for the QR panel: a loopback request back to this same
975 * site, carrying an Authorization header, to find out whether the header
976 * survives the trip to PHP. When it doesn't, pairing still succeeds (that
977 * endpoint is public) and every call after it fails, so the admin gets warned
978 * before scanning rather than after three revoked devices.
979 *
980 * The probe deliberately sends a Bearer token, not Basic credentials: it is a
981 * question about the header, not a login attempt, and a fake Basic login would
982 * feed the site's own IP to brute-force counters.
983 */
984 public function rest_pair_check( $request ) {
985 $response = wp_remote_get( rest_url( 'mwai/v1/workspace/auth-check' ), [
986 'timeout' => 10,
987 'headers' => [ 'Authorization' => 'Bearer mwai-header-probe' ],
988 'sslverify' => apply_filters( 'mwai_workspace_self_test_sslverify', true ),
989 ] );
990 // A blocked or failing loopback says nothing about the header: stay quiet
991 // rather than warn about a problem that may not exist.
992 if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
993 return new WP_REST_Response( [ 'success' => true, 'status' => 'unknown' ], 200 );
994 }
995 $body = json_decode( wp_remote_retrieve_body( $response ), true );
996 if ( !is_array( $body ) || !array_key_exists( 'authorization_header', $body ) ) {
997 return new WP_REST_Response( [ 'success' => true, 'status' => 'unknown' ], 200 );
998 }
999 if ( empty( $body['authorization_header'] ) ) {
1000 return new WP_REST_Response( [
1001 'success' => true,
1002 'status' => 'header_stripped',
1003 // No URL inline here: the admin panel renders doc_url as a real link.
1004 'message' => 'This server does not forward the Authorization header to WordPress, so pairing will look like it worked but the app will not be able to connect.',
1005 'doc_url' => self::DOC_MOBILE_URL,
1006 ], 200 );
1007 }
1008 return new WP_REST_Response( [ 'success' => true, 'status' => 'ok' ], 200 );
1009 }
1010
1011 public function rest_devices( $request ) {
1012 if ( !class_exists( 'WP_Application_Passwords' ) ) {
1013 return new WP_REST_Response( [ 'success' => true, 'devices' => [], 'available' => false ], 200 );
1014 }
1015 $passwords = WP_Application_Passwords::get_user_application_passwords( get_current_user_id() );
1016 $devices = [];
1017 foreach ( (array) $passwords as $p ) {
1018 if ( isset( $p['name'] ) && strpos( $p['name'], self::PAIR_APP_PREFIX ) === 0 ) {
1019 $devices[] = [
1020 'uuid' => $p['uuid'] ?? '',
1021 'name' => $p['name'],
1022 'created' => $p['created'] ?? null,
1023 'last_used' => $p['last_used'] ?? null,
1024 'last_ip' => $p['last_ip'] ?? null,
1025 ];
1026 }
1027 }
1028 return new WP_REST_Response( [ 'success' => true, 'devices' => $devices, 'available' => $this->pairing_available( wp_get_current_user() ) ], 200 );
1029 }
1030
1031 public function rest_device_revoke( $request ) {
1032 $params = $request->get_json_params();
1033 $uuid = isset( $params['uuid'] ) ? sanitize_text_field( (string) $params['uuid'] ) : '';
1034 if ( $uuid === '' || !class_exists( 'WP_Application_Passwords' ) ) {
1035 return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid device.' ], 400 );
1036 }
1037 // Only revoke our own paired devices, and only the current user's.
1038 $userId = get_current_user_id();
1039 $target = WP_Application_Passwords::get_user_application_password( $userId, $uuid );
1040 if ( !$target || strpos( $target['name'] ?? '', self::PAIR_APP_PREFIX ) !== 0 ) {
1041 return new WP_REST_Response( [ 'success' => false, 'message' => 'That device was not found.' ], 404 );
1042 }
1043 $res = WP_Application_Passwords::delete_application_password( $userId, $uuid );
1044 if ( is_wp_error( $res ) || !$res ) {
1045 return new WP_REST_Response( [ 'success' => false, 'message' => 'Could not revoke that device.' ], 200 );
1046 }
1047 return new WP_REST_Response( [ 'success' => true ], 200 );
1048 }
1049
1050 #endregion
1051
1052 #region Images (expiry info + move to Media Library)
1053
1054 private function get_file_row_by_url( $url ) {
1055 global $wpdb;
1056 return $wpdb->get_row( $wpdb->prepare(
1057 "SELECT * FROM {$wpdb->prefix}mwai_files WHERE url = %s",
1058 $url
1059 ), ARRAY_A );
1060 }
1061
1062 public function rest_image_info( $request ) {
1063 $params = $request->get_json_params();
1064 $urls = isset( $params['urls'] ) && is_array( $params['urls'] ) ? array_slice( $params['urls'], 0, 30 ) : [];
1065 $images = [];
1066 foreach ( $urls as $url ) {
1067 $url = esc_url_raw( (string) $url );
1068 if ( empty( $url ) ) {
1069 continue;
1070 }
1071 $row = $this->get_file_row_by_url( $url );
1072 if ( $row ) {
1073 $images[$url] = [
1074 'status' => 'file',
1075 // The files table stores UTC (WP pins PHP to UTC).
1076 'expires' => !empty( $row['expires'] ) ? strtotime( $row['expires'] . ' +0000' ) : null,
1077 ];
1078 }
1079 else {
1080 $images[$url] = [ 'status' => attachment_url_to_postid( $url ) ? 'library' : 'unknown' ];
1081 }
1082 }
1083 return new WP_REST_Response( [ 'success' => true, 'now' => time(), 'images' => $images ], 200 );
1084 }
1085
1086 public function rest_image_persist( $request ) {
1087 $params = $request->get_json_params();
1088 $url = esc_url_raw( (string) ( $params['url'] ?? '' ) );
1089 $chatId = sanitize_text_field( (string) ( $params['chatId'] ?? '' ) );
1090 $row = $url ? $this->get_file_row_by_url( $url ) : null;
1091 if ( !$row || empty( $row['path'] ) || !file_exists( $row['path'] ) ) {
1092 return new WP_REST_Response( [ 'success' => false, 'message' => 'This image is not available anymore.' ], 404 );
1093 }
1094 $filetype = wp_check_filetype( $row['path'] );
1095 if ( empty( $filetype['type'] ) || strpos( $filetype['type'], 'image/' ) !== 0 ) {
1096 return new WP_REST_Response( [ 'success' => false, 'message' => 'Only images can be saved to the Media Library.' ], 400 );
1097 }
1098
1099 $upload = wp_upload_bits( basename( $row['path'] ), null, file_get_contents( $row['path'] ) );
1100 if ( !empty( $upload['error'] ) ) {
1101 return new WP_REST_Response( [ 'success' => false, 'message' => $upload['error'] ], 500 );
1102 }
1103 $attachmentId = wp_insert_attachment( [
1104 'post_mime_type' => $filetype['type'],
1105 'post_title' => sanitize_file_name( pathinfo( $row['path'], PATHINFO_FILENAME ) ),
1106 'post_status' => 'inherit',
1107 ], $upload['file'] );
1108 if ( is_wp_error( $attachmentId ) ) {
1109 return new WP_REST_Response( [ 'success' => false, 'message' => $attachmentId->get_error_message() ], 500 );
1110 }
1111 require_once ABSPATH . 'wp-admin/includes/image.php';
1112 wp_update_attachment_metadata( $attachmentId, wp_generate_attachment_metadata( $attachmentId, $upload['file'] ) );
1113 $newUrl = wp_get_attachment_url( $attachmentId );
1114
1115 // Rewrite the URL in the conversation history so it survives the temp
1116 // file's expiration (and this Workspace reload shows the new URL).
1117 if ( !empty( $chatId ) && !empty( $this->core->discussions ) ) {
1118 $discussion = $this->core->discussions->get_discussion( self::BOT_ID, $chatId );
1119 if ( $discussion ) {
1120 $messages = $discussion['messages'];
1121 foreach ( $messages as $m ) {
1122 if ( isset( $m->content ) && is_string( $m->content ) ) {
1123 $m->content = str_replace( $url, $newUrl, $m->content );
1124 }
1125 }
1126 global $wpdb;
1127 $wpdb->update(
1128 $this->core->discussions->table_chats,
1129 [ 'messages' => wp_json_encode( $messages ), 'updated' => date( 'Y-m-d H:i:s' ) ],
1130 [ 'id' => $discussion['id'] ]
1131 );
1132 }
1133 }
1134
1135 // Drop the temp file (record + physical) now that the attachment owns the
1136 // image; the history no longer references the old URL.
1137 if ( !empty( $this->core->files ) ) {
1138 $this->core->files->delete_files( [ (int) $row['id'] ] );
1139 }
1140
1141 return new WP_REST_Response( [ 'success' => true, 'url' => $newUrl, 'attachmentId' => $attachmentId ], 200 );
1142 }
1143
1144 #endregion
1145
1146 private function get_prefs() {
1147 $defaults = [
1148 'theme' => 'dark', 'accent' => 'blue', 'envId' => null, 'model' => null,
1149 'collapsed' => false, 'prompts' => [],
1150 'pinned' => [ 'uploads', 'knowledge' ],
1151 'knowledgeEnvId' => null, 'mcpServers' => [], 'functions' => [],
1152 'imageMode' => false,
1153 'webSearchMode' => false,
1154 'wpMode' => false, 'wpCategories' => [ 'AI Engine (Core)' ],
1155 'advanced' => [ 'temperature' => null, 'reasoningEffort' => null ],
1156 'pinnedChats' => [],
1157 'folders' => [],
1158 ];
1159 $prefs = get_user_meta( get_current_user_id(), self::PREFS_META, true );
1160 return is_array( $prefs ) ? array_merge( $defaults, $prefs ) : $defaults;
1161 }
1162
1163 public function rest_prefs( $request ) {
1164 if ( $request->get_method() === 'POST' ) {
1165 $params = $request->get_json_params();
1166 $prefs = $this->get_prefs();
1167 if ( isset( $params['theme'] ) && in_array( $params['theme'], [ 'dark', 'light' ], true ) ) {
1168 $prefs['theme'] = $params['theme'];
1169 }
1170 if ( isset( $params['accent'] ) ) {
1171 $prefs['accent'] = sanitize_key( $params['accent'] );
1172 }
1173 if ( array_key_exists( 'envId', $params ) ) {
1174 $prefs['envId'] = $params['envId'] ? sanitize_text_field( $params['envId'] ) : null;
1175 }
1176 if ( array_key_exists( 'model', $params ) ) {
1177 $prefs['model'] = $params['model'] ? sanitize_text_field( $params['model'] ) : null;
1178 }
1179 if ( isset( $params['collapsed'] ) ) {
1180 $prefs['collapsed'] = (bool) $params['collapsed'];
1181 }
1182 if ( isset( $params['imageMode'] ) ) {
1183 $prefs['imageMode'] = (bool) $params['imageMode'];
1184 }
1185 if ( isset( $params['webSearchMode'] ) ) {
1186 $prefs['webSearchMode'] = (bool) $params['webSearchMode'];
1187 }
1188 if ( isset( $params['wpMode'] ) ) {
1189 $prefs['wpMode'] = (bool) $params['wpMode'];
1190 }
1191 if ( isset( $params['wpCategories'] ) && is_array( $params['wpCategories'] ) ) {
1192 $prefs['wpCategories'] = array_values( array_filter( array_map(
1193 'sanitize_text_field',
1194 array_slice( $params['wpCategories'], 0, 20 )
1195 ) ) );
1196 }
1197 if ( isset( $params['advanced'] ) && is_array( $params['advanced'] ) ) {
1198 $temp = $params['advanced']['temperature'] ?? null;
1199 $effort = $params['advanced']['reasoningEffort'] ?? null;
1200 $prefs['advanced'] = [
1201 'temperature' => is_numeric( $temp ) ? max( 0, min( 2, (float) $temp ) ) : null,
1202 'reasoningEffort' => in_array( $effort, [ 'low', 'medium', 'high' ], true ) ? $effort : null,
1203 ];
1204 }
1205 if ( isset( $params['folders'] ) && is_array( $params['folders'] ) ) {
1206 $clean = [];
1207 foreach ( array_slice( $params['folders'], 0, 30 ) as $f ) {
1208 if ( !is_array( $f ) ) {
1209 continue;
1210 }
1211 $name = mb_substr( sanitize_text_field( $f['name'] ?? '' ), 0, 60 );
1212 if ( $name === '' ) {
1213 continue;
1214 }
1215 $clean[] = [
1216 'id' => sanitize_key( $f['id'] ?? uniqid( 'f' ) ),
1217 'name' => $name,
1218 'collapsed' => !empty( $f['collapsed'] ),
1219 'chats' => array_values( array_filter( array_map(
1220 'sanitize_text_field',
1221 array_slice( (array) ( $f['chats'] ?? [] ), 0, 200 )
1222 ) ) ),
1223 ];
1224 }
1225 $prefs['folders'] = $clean;
1226 }
1227 if ( isset( $params['pinnedChats'] ) && is_array( $params['pinnedChats'] ) ) {
1228 $prefs['pinnedChats'] = array_values( array_filter( array_map(
1229 'sanitize_text_field',
1230 array_slice( $params['pinnedChats'], 0, 200 )
1231 ) ) );
1232 }
1233 if ( array_key_exists( 'knowledgeEnvId', $params ) ) {
1234 $prefs['knowledgeEnvId'] = $params['knowledgeEnvId'] ? sanitize_text_field( $params['knowledgeEnvId'] ) : null;
1235 }
1236 if ( isset( $params['pinned'] ) && is_array( $params['pinned'] ) ) {
1237 $allowed = [ 'uploads', 'image', 'web_search', 'knowledge', 'mcp', 'functions', 'wordpress' ];
1238 $prefs['pinned'] = array_values( array_intersect( array_map( 'sanitize_key', $params['pinned'] ), $allowed ) );
1239 }
1240 if ( isset( $params['mcpServers'] ) && is_array( $params['mcpServers'] ) ) {
1241 $prefs['mcpServers'] = array_values( array_filter( array_map(
1242 'sanitize_text_field',
1243 array_slice( $params['mcpServers'], 0, 50 )
1244 ) ) );
1245 }
1246 if ( isset( $params['functions'] ) && is_array( $params['functions'] ) ) {
1247 $clean = [];
1248 foreach ( array_slice( $params['functions'], 0, 100 ) as $f ) {
1249 if ( is_array( $f ) && !empty( $f['id'] ) && !empty( $f['type'] ) ) {
1250 $clean[] = [ 'type' => sanitize_text_field( $f['type'] ), 'id' => sanitize_text_field( $f['id'] ) ];
1251 }
1252 }
1253 $prefs['functions'] = $clean;
1254 }
1255 if ( isset( $params['prompts'] ) && is_array( $params['prompts'] ) ) {
1256 $clean = [];
1257 foreach ( array_slice( $params['prompts'], 0, 200 ) as $p ) {
1258 if ( !is_array( $p ) ) {
1259 continue;
1260 }
1261 $title = mb_substr( sanitize_text_field( $p['title'] ?? '' ), 0, 200 );
1262 $content = mb_substr( sanitize_textarea_field( $p['content'] ?? '' ), 0, 20000 );
1263 if ( $title === '' && $content === '' ) {
1264 continue;
1265 }
1266 $clean[] = [
1267 'id' => sanitize_key( $p['id'] ?? uniqid( 'p' ) ),
1268 'title' => $title,
1269 'content' => $content,
1270 ];
1271 }
1272 $prefs['prompts'] = $clean;
1273 }
1274 update_user_meta( get_current_user_id(), self::PREFS_META, $prefs );
1275 return new WP_REST_Response( [ 'success' => true, 'prefs' => $prefs ], 200 );
1276 }
1277 return new WP_REST_Response( [ 'success' => true, 'prefs' => $this->get_prefs() ], 200 );
1278 }
1279
1280 #endregion
1281 }
1282