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