advisor.php
4 months ago
chatbot.php
4 days ago
discussions.php
4 days ago
editor-assistant.php
4 months ago
files.php
4 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
4 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 |