| Server IP : 121.121.20.254 / Your IP : 216.73.216.202 Web Server : Microsoft-IIS/10.0 System : Windows NT WEB-SERVER 10.0 build 20348 (Windows Server 2022) AMD64 User : IUSR ( 0) PHP Version : 8.3.28 Disable Function : NONE MySQL : ON | cURL : ON | WGET : OFF | Perl : OFF | Python : OFF | Sudo : OFF | Pkexec : OFF Directory : C:/inetpub/wwwroot/AIWEBSTATION/wp-content/plugins/ai-engine-pro/classes/modules/ |
Upload File : |
<?php
/**
* Workspace: the full-screen, TypingMind-class chat surface inside wp-admin.
* Free shell, admins only for now (per-role access comes later); Knowledge,
* MCP Servers and Functions inside it are Pro. It runs on the
* same chat pipeline as the chatbot (chats/submit + discussions) through the
* internal "mwai_workspace" bot, with per-conversation env/model overrides.
* Theme, accent and default model are per-user preferences (user meta).
*/
/**
* Thrown by the WordPress Tools bridge when the AI wants to run a tool that
* needs the user's approval. Not an error: the chat pipeline turns it into an
* approval card in the UI and the turn resumes once the user decides.
*/
class Meow_MWAI_ApprovalRequiredException extends Exception {
public $tool;
public $args;
public function __construct( $tool, $args = [] ) {
parent::__construct( "The tool '{$tool}' requires the user's approval." );
$this->tool = (string) $tool;
$this->args = is_array( $args ) ? $args : [];
}
}
class Meow_MWAI_Modules_Workspace {
private $core = null;
// "WordPress Tools" state for the current chat request: the categories the
// user enabled, and the tool names attached to the query (feedback routing).
private $wpToolsCategories = null;
private $wpToolNames = [];
// Approval state for the current chat request: tool names the user allowed
// (once or for the conversation) and tool names they explicitly denied.
private $wpToolsAllowed = [];
private $wpToolsDenied = [];
private $wpToolsTurnKey = '';
public const PREFS_META = 'mwai_workspace_prefs';
public const BOT_ID = 'mwai_workspace';
/**
* What this site's Workspace API can do, for clients that ship separately from
* the plugin (the mobile app updates through the App Store, sites update when
* they feel like it, so the two drift). Bump this whenever a client would need
* to know that a site is new enough for something, and never renumber it.
*
* ADDITIVE ONLY, and this covers two things:
*
* 1. Routes under mwai/v1/workspace. Renaming or removing one breaks every
* phone in the wild until its owner updates the app.
* 2. Keys inside the payloads those routes return, build_bootstrap() above in
* particular. Adding a key is always safe because clients ignore what they
* do not know; removing or renaming one is not. The mobile app decodes that
* payload strictly and treats a failed decode as a failed connection, so
* dropping a key like modules.embeddings or wp_tools.site_name would not
* degrade those devices, it would make their sites unconnectable. A rename
* is one quiet line in a plugin diff and total at the other end.
*
* To retire a key: add its replacement, keep sending the old one, bump this
* constant, and only drop the old key once you are willing to tell everyone on
* an older app to update.
*
* 1: pairing, bootstrap, prefs, wp-tools, images, auth-check, pair-check.
*/
public const API_VERSION = 1;
/**
* MCP tools that must never be bridged into the Workspace conversation.
*
* mwai_image generates an image and hands back { id, url } as plain text. The image
* is stored in the Media Library but never becomes part of the conversation, so the
* model cannot see it on the next turn and follow-ups like "make it wider" or "change
* the background" have nothing to work from. The Workspace already generates images
* through the native image_generation tool (gated by the workspace_image option),
* which keeps the result attached to the chat, so bridging this one only added a
* second, broken path to the same feature.
*/
private const EXCLUDED_WP_TOOLS = [ 'mwai_image' ];
public function __construct( $core ) {
$this->core = $core;
add_action( 'admin_menu', [ $this, 'admin_menu' ] );
add_action( 'admin_enqueue_scripts', [ $this, 'admin_enqueue_scripts' ] );
add_action( 'admin_head', [ $this, 'admin_head' ] );
// Hidden pages (no menu parent) get no title from WP: set it ourselves.
add_filter( 'admin_title', [ $this, 'admin_title' ], 10, 2 );
add_action( 'rest_api_init', [ $this, 'rest_api_init' ] );
add_filter( 'mwai_internal_chatbot', [ $this, 'internal_chatbot' ], 10, 3 );
// "This WordPress": expose the site's MCP tools to the AI as plain
// functions, so every provider (not just MCP-capable ones) can drive
// this WordPress without a network round-trip.
add_filter( 'mwai_chatbot_query', [ $this, 'chatbot_query' ], 10, 2 );
add_filter( 'mwai_ai_feedback', [ $this, 'ai_feedback' ], 10, 3 );
// Multi-step site work (find, read, edit, verify) burns one depth level
// per tool round; the default of 5 cuts legitimate tasks short.
add_filter( 'mwai_function_call_max_depth', function ( $maxDepth, $query ) {
return ( $query->scope ?? '' ) === 'workspace' ? max( 20, (int) $maxDepth ) : $maxDepth;
}, 10, 2 );
// Mobile companion (QR pairing): authorize Application-Password requests
// from admins on AI Engine's nonce-gated endpoints. See authorize_app_password().
add_filter( 'mwai_rest_authorized', [ $this, 'authorize_app_password' ], 10, 2 );
}
/**
* AI Engine's chat/discussion endpoints gate on a wp_rest nonce (CSRF
* protection for the in-admin, cookie-authenticated web app). The iOS
* companion authenticates with a WordPress Application Password (HTTP Basic),
* which carries no nonce. Authorize those requests when, and only when, they
* were authenticated by an Application Password (NOT a cookie) AND resolve to
* an admin. Cookie-authenticated browser requests are untouched, so their CSRF
* gate stays intact; Basic auth isn't CSRF-exposed, so satisfying the nonce for
* a capable user is safe (they can already use the whole WP REST API).
*
* The check asks WordPress what authenticated THIS request rather than reading
* the Authorization header. Servers differ on how Basic credentials reach PHP:
* Apache with mod_php consumes the header into PHP_AUTH_USER and never exposes
* it as HTTP_AUTHORIZATION, so a header test failed there even though the app
* password had authenticated the user perfectly, and every chat message from
* the mobile app came back as 403. rest_get_authenticated_app_password() is set
* by core itself, so it is right on every server config, and it can never be
* true for a browser's cookie session.
*/
public function authorize_app_password( $authorized, $request ) {
if ( $authorized ) {
return $authorized;
}
if ( function_exists( 'rest_get_authenticated_app_password' ) && rest_get_authenticated_app_password() ) {
return current_user_can( 'manage_options' ) ? true : $authorized;
}
return $authorized;
}
public function can_access() {
return current_user_can( 'manage_options' );
}
private function is_pro() {
return (bool) apply_filters( MWAI_PREFIX . '_meowapps_is_registered', false, MWAI_PREFIX );
}
/**
* Effective feature availability: the admin-level Settings toggles, with
* Knowledge, MCP Servers and Functions additionally requiring Pro (their
* underlying modules are Pro anyway).
*/
private function feature_flags() {
$pro = $this->is_pro();
return [
'image' => (bool) $this->core->get_option( 'workspace_image' ),
'web_search' => (bool) $this->core->get_option( 'workspace_web_search' ),
'wp_tools' => (bool) $this->core->get_option( 'workspace_wp_tools' ),
'knowledge' => $pro && (bool) $this->core->get_option( 'workspace_knowledge' ),
'mcp' => $pro && (bool) $this->core->get_option( 'workspace_mcp' ),
'functions' => $pro && (bool) $this->core->get_option( 'workspace_functions' ),
];
}
private function is_workspace_page() {
return is_admin() && isset( $_GET['page'] ) && $_GET['page'] === 'mwai_workspace';
}
public function admin_menu() {
// No menu entry: the Workspace is reached from the Admin Bar and the
// AI Engine header. An empty parent registers the page without listing it,
// so admin.php?page=mwai_workspace keeps working.
add_submenu_page(
'',
'AI Workspace',
'Workspace',
'manage_options',
'mwai_workspace',
[ $this, 'render_page' ]
);
}
public function admin_title( $admin_title, $title ) {
if ( $this->is_workspace_page() ) {
return 'AI Workspace' . $admin_title;
}
return $admin_title;
}
public function render_page() {
if ( !$this->can_access() ) {
wp_die( __( 'Sorry, you are not allowed to access this page.', 'ai-engine' ), 403 );
}
echo '<div id="mwai-workspace" data-theme="dark"></div>';
}
/**
* The Workspace takes the whole viewport: hide the WP admin chrome on its
* page only, like the Site Editor does. The div itself is position:fixed.
*/
public function admin_head() {
if ( !$this->is_workspace_page() ) {
return;
}
echo '<style>
#adminmenumain, #wpadminbar, #wpfooter, #screen-meta, #screen-meta-links { display: none !important; }
html.wp-toolbar { padding-top: 0 !important; }
#wpcontent, #wpbody-content { margin-left: 0 !important; padding: 0 !important; }
#wpbody-content .notice, #wpbody-content .updated, #wpbody-content .error { display: none !important; }
</style>';
}
public function admin_enqueue_scripts() {
if ( !$this->is_workspace_page() || !$this->can_access() ) {
return;
}
$physical_file = MWAI_PATH . '/app/workspace.js';
$cache_buster = file_exists( $physical_file ) ? filemtime( $physical_file ) : MWAI_VERSION;
wp_register_script(
'mwai_workspace',
MWAI_URL . 'app/workspace.js',
[ 'wp-element', 'wp-i18n' ],
$cache_buster
);
wp_enqueue_script( 'mwai_workspace' );
// Syntax highlighting is always on in the Workspace, independently of the
// chatbot's syntax_highlight option. The bundled dark theme suits the
// Workspace's code blocks, which stay dark in both themes.
wp_enqueue_script( 'mwai_highlight' );
wp_enqueue_style( 'mwai_workspace_highlight', MWAI_URL . 'vendor/highlightjs/stackoverflow-dark.min.css', [], '11.7' );
wp_enqueue_style( 'mwai_workspace', MWAI_URL . 'app/workspace.css', [], $cache_buster );
// The Workspace type system (self-hosting these is on the roadmap).
wp_enqueue_style(
'mwai_workspace_fonts',
'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',
[],
null
);
wp_localize_script( 'mwai_workspace', 'mwai_workspace', array_merge( $this->build_bootstrap(), [
// Page-only bits: the web app authenticates with the cookie + nonce, the
// mobile companion with an Application Password (no nonce, no session).
'rest_nonce' => wp_create_nonce( 'wp_rest' ),
'session' => $this->core->get_session_id(),
'debug_mode' => (bool) ( $this->core->get_option( 'module_devtools' ) && $this->core->get_option( 'debug_mode' ) ),
] ) );
}
/**
* Everything a Workspace client needs to start: the model catalog, the
* per-user preferences, the feature flags and the tool catalog. Shared by the
* admin page (localized at load) and the mobile companion (one REST call).
*/
private function build_bootstrap() {
$user = wp_get_current_user();
return [
// Version handshake. A client that needs something this site cannot do
// should be able to say "update AI Engine" instead of guessing at a 404
// from a route that does not exist yet. The plugin version is for humans
// and bug reports; api_version is what clients compare against.
'plugin_version' => MWAI_VERSION,
'api_version' => self::API_VERSION,
'rest_url' => untrailingslashit( get_rest_url() ),
'admin_url' => admin_url(),
'api_url' => untrailingslashit( get_rest_url( null, 'mwai/v1' ) ),
'plugin_url' => untrailingslashit( MWAI_URL ),
'site_name' => get_bloginfo( 'name' ),
'site_url' => untrailingslashit( home_url() ),
'stream' => (bool) $this->core->get_option( 'ai_streaming' ),
'user' => [
'display_name' => $user->display_name,
'role' => !empty( $user->roles ) ? $user->roles[0] : '',
],
'prefs' => $this->get_prefs(),
'envs' => $this->build_envs(),
'embeddings_envs' => $this->build_embeddings_envs(),
'mcp_envs' => $this->build_mcp_envs(),
'functions' => $this->build_functions(),
'wp_tools' => $this->build_wp_tools(),
'modules' => [
'embeddings' => (bool) $this->core->get_option( 'module_embeddings' ),
'orchestration' => (bool) $this->core->get_option( 'module_orchestration' ),
],
// Admin-level feature availability (Settings > Workspace + Pro).
'features' => $this->feature_flags(),
// Features that exist but need Pro: shown as tasteful locked rows.
'locked_features' => $this->is_pro() ? [] : [ 'knowledge', 'mcp', 'functions' ],
'is_pro' => $this->is_pro(),
'upsell_url' => 'https://meowapps.com/ai-engine/',
'settings_url' => admin_url( 'admin.php?page=mwai_settings' ),
];
}
private function build_embeddings_envs() {
if ( !$this->core->get_option( 'module_embeddings' ) ) {
return [];
}
$out = [];
foreach ( (array) $this->core->get_option( 'embeddings_envs' ) as $env ) {
if ( !empty( $env['id'] ) ) {
$out[] = [
'id' => $env['id'],
'name' => $env['name'] ?? $env['id'],
'type' => $env['type'] ?? '',
];
}
}
return $out;
}
private function build_mcp_envs() {
if ( !$this->core->get_option( 'module_orchestration' ) ) {
return [];
}
$out = [];
foreach ( (array) $this->core->get_option( 'mcp_envs' ) as $env ) {
if ( !empty( $env['id'] ) ) {
$out[] = [ 'id' => $env['id'], 'name' => $env['name'] ?? $env['id'] ];
}
}
return $out;
}
private function build_functions() {
$out = [];
$functions = apply_filters( 'mwai_functions_list', [] );
foreach ( (array) $functions as $f ) {
$f = is_object( $f ) ? (array) $f : $f;
if ( !is_array( $f ) || empty( $f['id'] ) || empty( $f['type'] ) ) {
continue;
}
// Editor Assistant functions only make sense inside the block editor.
if ( $f['type'] === 'editor-assistant' ) {
continue;
}
$out[] = [
'id' => (string) $f['id'],
'name' => $f['name'] ?? (string) $f['id'],
'desc' => $f['description'] ?? '',
'type' => $f['type'],
];
}
return $out;
}
/**
* The env + model catalog for the picker: every AI environment with its
* chat-capable models (same filtering the smoke gate uses).
*/
private function build_envs() {
$out = [];
$envs = $this->core->get_option( 'ai_envs' );
if ( !is_array( $envs ) ) {
return $out;
}
foreach ( $envs as $env ) {
try {
$engine = Meow_MWAI_Engines_Factory::get( $this->core, $env['id'] );
if ( !$engine ) {
continue;
}
$models = $engine->get_models();
$chatModels = [];
foreach ( (array) $models as $m ) {
$tags = $m['tags'] ?? [];
$features = $m['features'] ?? [];
if ( !in_array( 'chat', $tags, true ) || in_array( 'deprecated', $tags, true ) ) {
continue;
}
if ( !empty( $features ) && !in_array( 'completion', $features, true ) ) {
continue;
}
if ( preg_match( '/guard|moderat|safety|embed|whisper|tts|rerank/i', $m['model'] ) ) {
continue;
}
$chatModels[] = [
'model' => $m['model'],
'name' => $m['name'] ?? $m['model'],
// Capability hints for the UI (files/functions warnings).
'tags' => array_values( array_intersect( $tags, [ 'vision', 'files', 'functions', 'reasoning', 'no-temperature' ] ) ),
// Whether the model can produce images in a chat turn: OpenAI via
// the image_generation tool, Gemini Flash Image natively.
'image' => in_array( 'image_generation', (array) ( $m['tools'] ?? [] ), true ) ||
in_array( 'image-generation', (array) $features, true ),
// Whether the provider runs web search server-side for this model. Sent as
// a resolved boolean like 'image' above: the raw tools array never reaches
// the UI, so checking it client-side always came back false.
'web_search' => in_array( 'web_search', (array) ( $m['tools'] ?? [] ), true ),
];
}
if ( !empty( $chatModels ) ) {
$out[] = [
'id' => $env['id'],
'name' => $env['name'],
'type' => $env['type'],
'models' => $chatModels,
];
}
}
catch ( Exception $e ) {
// An unusable env (bad key, unknown type) simply doesn't appear.
continue;
}
}
return $out;
}
/**
* The internal bot behind the Workspace: based on the default chatbot so all
* pipeline expectations hold, with a neutral personality. Client-sent envId
* and model override it per conversation.
*/
public function internal_chatbot( $chatbot, $botId, $params ) {
if ( $botId !== self::BOT_ID || !empty( $chatbot ) ) {
return $chatbot;
}
if ( !is_user_logged_in() || !$this->can_access() ) {
return $chatbot;
}
$instructions = 'You are a capable, direct assistant. Format your answers in Markdown.';
// "This WordPress" mode: remember the requested categories for this
// request (chatbot_query attaches the tools) and tell the AI where it is.
$wpTools = $params['wpTools'] ?? null;
if ( is_array( $wpTools ) && !empty( $wpTools ) && $this->core->get_option( 'workspace_wp_tools' ) ) {
$this->wpToolsCategories = array_values( array_filter( array_map(
'sanitize_text_field',
array_slice( $wpTools, 0, 20 )
) ) );
// The user's approval decisions travel with the request (per chat).
foreach ( [ 'wpToolsAllowed', 'wpToolsDenied' ] as $key ) {
if ( isset( $params[$key] ) && is_array( $params[$key] ) ) {
$this->{$key} = array_values( array_filter( array_map(
'sanitize_text_field',
array_slice( $params[$key], 0, 50 )
) ) );
}
}
// Identifies the current turn for the write-tool result cache.
$this->wpToolsTurnKey = md5( ( $params['chatId'] ?? '' ) . '|' . ( $params['newMessage'] ?? '' ) );
$instructions .= "\n\nYou have direct tool access to this WordPress site: " .
get_bloginfo( 'name' ) . ' (' . home_url() . '). Use the available tools to read, ' .
'analyze and modify the site when the user asks. Before a destructive or ' .
'hard-to-undo change (deleting content, changing options), state what you are ' .
'about to do and ask for confirmation first.';
}
$base = $this->core->get_chatbot( 'default' );
$base = is_array( $base ) ? $base : [];
return array_merge( $base, [
'botId' => self::BOT_ID,
'name' => 'Workspace',
'scope' => 'workspace',
'instructions' => $instructions,
'startSentence' => '',
'localMemory' => false,
'window' => false,
'fullscreen' => false,
'textInputMaxLength' => 32000,
'maxMessages' => 100,
'contentAware' => false,
'imageUpload' => true,
'fileUpload' => true,
'multiUpload' => true,
// The default bot's tools/knowledge must NOT leak into the Workspace:
// its features are per-conversation, so only client-sent selections
// apply (they merge over this config in chat_submit).
'functions' => [],
'mcpServers' => [],
'tools' => [],
'embeddingsEnvId' => '',
'embeddingsIndex' => '',
'embeddingsNamespace' => '',
] );
}
#region This WordPress (MCP tools as functions)
private function get_mcp_tools() {
static $tools = null;
if ( $tools === null ) {
$tools = $this->core->get_option( 'module_mcp' ) ? apply_filters( 'mwai_mcp_tools', [] ) : [];
$tools = is_array( $tools ) ? $tools : [];
}
return $tools;
}
/**
* Category catalog for the composer's "This WordPress" popover.
*/
private function build_wp_tools() {
$categories = [];
foreach ( $this->get_mcp_tools() as $tool ) {
if ( empty( $tool['name'] ) ) {
continue;
}
// Keep the popover honest: never advertise (or count) a tool the query builder
// refuses to attach.
if ( in_array( $tool['name'], self::EXCLUDED_WP_TOOLS, true ) ) {
continue;
}
// Same default as the MCP server's tools listing.
$cat = !empty( $tool['category'] ) ? (string) $tool['category'] : 'AI Engine (Core)';
if ( !isset( $categories[$cat] ) ) {
$categories[$cat] = [ 'name' => $cat, 'count' => 0, 'tools' => [] ];
}
$categories[$cat]['count']++;
$categories[$cat]['tools'][] = (string) $tool['name'];
}
ksort( $categories );
return [
'enabled' => (bool) $this->core->get_option( 'module_mcp' ) && (bool) $this->core->get_option( 'workspace_wp_tools' ),
'site_name' => get_bloginfo( 'name' ),
'categories' => array_values( $categories ),
];
}
public function chatbot_query( $query, $params ) {
// Enforce the effective feature availability (Settings > Workspace + Pro).
// The Workspace UI hides disabled features; this is the server-side gate.
if ( ( $query->scope ?? '' ) === 'workspace' ) {
$flags = $this->feature_flags();
if ( !$flags['mcp'] && isset( $query->mcpServers ) ) {
$query->mcpServers = [];
}
if ( !$flags['functions'] && !empty( $query->functions ) ) {
$query->set_functions( [] );
}
if ( !$flags['image'] && !empty( $query->tools ) ) {
$query->tools = array_values( array_diff( $query->tools, [ 'image_generation' ] ) );
}
if ( !$flags['web_search'] && !empty( $query->tools ) ) {
$query->tools = array_values( array_diff( $query->tools, [ 'web_search' ] ) );
}
if ( !$flags['knowledge'] && isset( $query->embeddingsEnvId ) ) {
$query->embeddingsEnvId = null;
}
}
if ( $this->wpToolsCategories === null || !$this->can_access() ) {
return $query;
}
$existing = [];
foreach ( (array) $query->functions as $fn ) {
$existing[$fn->name] = true;
}
foreach ( $this->get_mcp_tools() as $tool ) {
$name = $tool['name'] ?? '';
$cat = !empty( $tool['category'] ) ? (string) $tool['category'] : 'AI Engine (Core)';
if ( empty( $name ) || isset( $existing[$name] ) || !in_array( $cat, $this->wpToolsCategories, true ) ) {
continue;
}
if ( in_array( $name, self::EXCLUDED_WP_TOOLS, true ) ) {
continue;
}
if ( !preg_match( '/^[a-zA-Z0-9_-]{1,64}$/', $name ) ) {
continue;
}
$query->add_function( Meow_MWAI_Query_Function::from_raw_schema(
$name,
(string) ( $tool['description'] ?? '' ),
is_array( $tool['inputSchema'] ?? null ) ? $tool['inputSchema'] : null,
'mcp-bridge'
) );
$existing[$name] = true;
$this->wpToolNames[$name] = true;
}
return $query;
}
/**
* A tool needs the user's approval when it can change the site. Core tools
* carry MCP annotations (readOnlyHint); tools without annotations are judged
* by their name, and unknown verbs count as writes: safe by default.
*/
private function tool_requires_approval( $name ) {
foreach ( $this->get_mcp_tools() as $tool ) {
if ( ( $tool['name'] ?? '' ) === $name ) {
$annotations = $tool['annotations'] ?? null;
if ( is_array( $annotations ) ) {
if ( array_key_exists( 'readOnlyHint', $annotations ) ) {
return empty( $annotations['readOnlyHint'] );
}
// Annotated, but silent about writing: the provider knows about
// annotations and still did not claim to be read-only, so ask. This
// covers Code Engine functions, whose name is whatever PHP snippet
// the user wrote and must never be trusted by the heuristic below.
return true;
}
break;
}
}
// No annotations at all: fall back to the name. A destructive verb wins
// over a read-only one, otherwise a tool like
// "dbclnr_run_custom_query_delete" reads as safe because it contains
// "query" and would wipe rows with no dialog.
if ( preg_match(
'/(^|_)(delete|remove|drop|truncate|purge|destroy|reset|flush|clear|write|upload|install|uninstall|activate|deactivate|execute|run|import|restore|optimize|repair)(_|$)/i',
$name
) ) {
return true;
}
return !preg_match(
'/(^|_)(get|list|count|search|query|check|compare|suggest|rank|preview|stats|statistics|status|profile|pulse|digest|mix|insights|ping)(_|$)/i',
$name
);
}
public function ai_feedback( $value, $needFeedback, $reply ) {
if ( $value !== null ) {
return $value;
}
$name = $needFeedback['name'] ?? null;
// Only tools this request explicitly attached are executable, and only
// for the (admin) user driving the Workspace.
if ( !$name || empty( $this->wpToolNames[$name] ) || !$this->can_access() ) {
return $value;
}
$args = $needFeedback['arguments'] ?? [];
if ( is_string( $args ) ) {
$decoded = json_decode( $args, true );
$args = is_array( $decoded ) ? $decoded : [];
}
// Approval gate: anything that writes to the site pauses the turn and
// asks the user in the UI, unless they already allowed this tool for the
// conversation (or this run). A denial is handed to the AI as the result.
if ( in_array( $name, $this->wpToolsDenied, true ) ) {
return "The user DENIED running '{$name}'. Do not retry it. Briefly acknowledge this and ask the user how they would like to proceed instead.";
}
$needsApproval = $this->tool_requires_approval( $name );
if ( !in_array( $name, $this->wpToolsAllowed, true ) && $needsApproval ) {
throw new Meow_MWAI_ApprovalRequiredException( $name, $args );
}
// Approving a call re-runs the whole turn, so a write that already ran in
// a previous pass of the SAME turn would execute twice (duplicate post,
// double delete). Identical calls within one turn return the first result
// instead. Keyed by chatId + user message, short-lived, writes only:
// reads stay fresh and a new user message never hits the cache.
$cacheKey = '';
if ( $needsApproval && !empty( $this->wpToolsTurnKey ) ) {
$cacheKey = 'mwai_ws_tool_' . md5( $this->wpToolsTurnKey . '|' . $name . '|' . wp_json_encode( $args ) );
$cached = get_transient( $cacheKey );
if ( is_array( $cached ) && array_key_exists( 'v', $cached ) ) {
return $cached['v'];
}
}
$out = null;
$failed = false;
try {
// Handlers type the JSON-RPC id as ?int; the provider tool-call id is a
// string ("call_..."), so pass a neutral int instead.
//
// The fifth argument is the MCP server instance, and there is none here:
// the Workspace runs tools directly rather than over the MCP endpoint. It
// is still passed, as null, so the filter has one arity everywhere. Firing
// four arguments here while labs/mcp.php fires five meant a third-party
// handler registered with add_filter( ..., 10, 5 ) worked over MCP and then
// died with an ArgumentCountError the moment the same tool ran in the
// Workspace. Handlers that want the instance must tolerate null.
$result = apply_filters( 'mwai_mcp_callback', null, $name, $args, 0, null );
if ( $result === null ) {
$out = "The tool '{$name}' is not available on this site.";
$failed = true;
}
else {
// Some handlers return a full JSON-RPC envelope: unwrap it.
if ( is_array( $result ) && isset( $result['jsonrpc'] ) ) {
if ( isset( $result['error'] ) ) {
$out = 'Error: ' . ( $result['error']['message'] ?? 'The tool failed.' );
$failed = true;
}
$result = $result['result'] ?? null;
}
if ( $out === null ) {
// MCP content envelope ({ content: [{type,text}], isError }): flatten
// the text parts, which is what the model actually needs.
if ( is_array( $result ) && isset( $result['content'] ) && is_array( $result['content'] ) ) {
$texts = [];
foreach ( $result['content'] as $part ) {
if ( isset( $part['text'] ) ) {
$texts[] = $part['text'];
}
}
$flat = implode( "\n", $texts );
$failed = !empty( $result['isError'] );
$out = $failed ? 'Error: ' . $flat : $flat;
}
else {
$out = $result;
}
}
}
}
catch ( Throwable $e ) {
$out = "Error: the tool '{$name}' crashed (" . $e->getMessage() . '). The other tools should still work.';
$failed = true;
}
// Only successful write results are cached (see the note above); failures
// may be retried fresh on the next pass.
if ( $cacheKey !== '' && !$failed ) {
set_transient( $cacheKey, [ 'v' => $out ], 5 * MINUTE_IN_SECONDS );
}
return $out;
}
#endregion
#region Preferences (per WordPress user)
public function rest_api_init() {
register_rest_route( 'mwai/v1', '/workspace/prefs', [
'methods' => [ 'GET', 'POST' ],
'callback' => [ $this, 'rest_prefs' ],
'permission_callback' => [ $this, 'can_access' ],
] );
// The full tool catalog only exists in REST context: several providers
// (AI Engine core among them) register their mwai_mcp_tools filter on
// rest_api_init, so the admin page's localized copy misses them.
register_rest_route( 'mwai/v1', '/workspace/wp-tools', [
'methods' => [ 'GET', 'POST' ],
'callback' => function () {
return new WP_REST_Response( [ 'success' => true, 'wp_tools' => $this->build_wp_tools() ], 200 );
},
'permission_callback' => [ $this, 'can_access' ],
] );
// One call for everything a client needs at launch (model catalog, prefs,
// feature flags, tool catalog). The mobile companion uses this instead of
// assembling it from settings/options + prefs + wp-tools.
register_rest_route( 'mwai/v1', '/workspace/bootstrap', [
'methods' => 'GET',
'callback' => function () {
return new WP_REST_Response( array_merge( [ 'success' => true ], $this->build_bootstrap() ), 200 );
},
'permission_callback' => [ $this, 'can_access' ],
] );
register_rest_route( 'mwai/v1', '/workspace/image-info', [
'methods' => 'POST',
'callback' => [ $this, 'rest_image_info' ],
'permission_callback' => [ $this, 'can_access' ],
] );
register_rest_route( 'mwai/v1', '/workspace/image-persist', [
'methods' => 'POST',
'callback' => [ $this, 'rest_image_persist' ],
'permission_callback' => [ $this, 'can_access' ],
] );
// Mobile pairing (QR connect).
register_rest_route( 'mwai/v1', '/workspace/pair-token', [
'methods' => 'POST',
'callback' => [ $this, 'rest_pair_token' ],
'permission_callback' => [ $this, 'can_access' ],
] );
// Public on purpose: gated entirely by the one-time, short-lived token.
register_rest_route( 'mwai/v1', '/workspace/pair', [
'methods' => 'POST',
'callback' => [ $this, 'rest_pair' ],
'permission_callback' => '__return_true',
] );
// Public on purpose: it only describes the caller's own request. See rest_auth_check().
register_rest_route( 'mwai/v1', '/workspace/auth-check', [
'methods' => 'GET',
'callback' => [ $this, 'rest_auth_check' ],
'permission_callback' => '__return_true',
] );
register_rest_route( 'mwai/v1', '/workspace/pair-check', [
'methods' => 'GET',
'callback' => [ $this, 'rest_pair_check' ],
'permission_callback' => [ $this, 'can_access' ],
] );
register_rest_route( 'mwai/v1', '/workspace/devices', [
'methods' => 'GET',
'callback' => [ $this, 'rest_devices' ],
'permission_callback' => [ $this, 'can_access' ],
] );
register_rest_route( 'mwai/v1', '/workspace/devices/revoke', [
'methods' => 'POST',
'callback' => [ $this, 'rest_device_revoke' ],
'permission_callback' => [ $this, 'can_access' ],
] );
}
#region Mobile pairing (QR connect)
// Application Passwords named with this prefix are "paired devices".
public const PAIR_APP_PREFIX = 'Workspace by AI Engine';
public const PAIR_TOKEN_TTL = 300; // 5 minutes.
/**
* Everything that can stop a phone from connecting (a server that drops the
* Authorization header, a security plugin filtering the REST API, a revoked
* device) is explained here. The messages below name the problem and link out
* rather than trying to teach .htaccess in a banner, the same way the MCP
* self-test points at the same page for its own failures.
*
* The fragment matters: that page opens on MCP and OAuth, so a phone user
* landing at the top would not recognise their problem. If the anchor is ever
* dropped from the page the link still works, it just lands higher up.
*/
public const DOC_MOBILE_URL = 'https://meowapps.com/fix-mcp-wordpress-connection/#workspace-mobile';
private function pairing_available( $user = null ) {
if ( !class_exists( 'WP_Application_Passwords' ) || !function_exists( 'wp_is_application_passwords_available' ) ) {
return false;
}
if ( !wp_is_application_passwords_available() ) {
return false;
}
if ( $user && !wp_is_application_passwords_available_for_user( $user ) ) {
return false;
}
return true;
}
private function pairing_unavailable_reason() {
if ( !function_exists( 'wp_is_application_passwords_available' ) || !wp_is_application_passwords_available() ) {
return 'Application Passwords are disabled on this site (they require HTTPS). Enable HTTPS or Application Passwords to connect a mobile app.';
}
return 'Application Passwords are not available for this account.';
}
/**
* Admin action: mint a short-lived, single-use pairing token and return the
* QR payload. The token's hash (not the token) is stored server-side.
*/
public function rest_pair_token( $request ) {
if ( !$this->pairing_available( wp_get_current_user() ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => $this->pairing_unavailable_reason() ], 200 );
}
try {
$token = bin2hex( random_bytes( 24 ) ); // 48 hex chars: infeasible to guess in 5 min.
}
catch ( Exception $e ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Could not generate a secure token.' ], 500 );
}
set_transient(
'mwai_pair_' . hash( 'sha256', $token ),
[ 'user_id' => get_current_user_id(), 't' => time() ],
self::PAIR_TOKEN_TTL
);
return new WP_REST_Response( [
'success' => true,
'payload' => [ 'v' => 1, 'url' => untrailingslashit( home_url() ), 'pair' => $token ],
'expires_in' => self::PAIR_TOKEN_TTL,
'site_name' => get_bloginfo( 'name' ),
], 200 );
}
/**
* Public: the mobile app exchanges a valid pairing token for a WordPress
* Application Password (created for the admin who generated the token).
*/
public function rest_pair( $request ) {
// Defense in depth (tokens are already unguessable): rate-limit per IP.
$ip = method_exists( $this->core, 'get_ip_address' ) ? $this->core->get_ip_address() : ( $_SERVER['REMOTE_ADDR'] ?? '' );
$rlKey = 'mwai_pair_rl_' . md5( (string) $ip );
$attempts = (int) get_transient( $rlKey );
if ( $attempts >= 20 ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Too many attempts. Please wait a minute.' ], 429 );
}
set_transient( $rlKey, $attempts + 1, MINUTE_IN_SECONDS );
$params = $request->get_json_params();
$token = isset( $params['token'] ) ? (string) $params['token'] : '';
$device = isset( $params['device'] ) ? mb_substr( sanitize_text_field( $params['device'] ), 0, 60 ) : '';
if ( $device === '' ) {
$device = 'Mobile device';
}
if ( $token === '' || !ctype_xdigit( $token ) || strlen( $token ) > 128 ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid pairing code.' ], 400 );
}
$tKey = 'mwai_pair_' . hash( 'sha256', $token );
$data = get_transient( $tKey );
if ( !is_array( $data ) || empty( $data['user_id'] ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'This pairing code has expired. Generate a new one.' ], 400 );
}
delete_transient( $tKey ); // single use.
$user = get_user_by( 'id', (int) $data['user_id'] );
if ( !$user || !user_can( $user, 'manage_options' ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'That account can no longer connect a mobile app.' ], 403 );
}
if ( !$this->pairing_available( $user ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => $this->pairing_unavailable_reason() ], 400 );
}
$created = WP_Application_Passwords::create_new_application_password(
$user->ID,
[ 'name' => self::PAIR_APP_PREFIX . ' - ' . $device ]
);
if ( is_wp_error( $created ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => $created->get_error_message() ], 500 );
}
list( $password, $item ) = $created;
return new WP_REST_Response( [
'success' => true,
'url' => untrailingslashit( home_url() ),
'rest_url' => untrailingslashit( get_rest_url() ),
'site_name' => get_bloginfo( 'name' ),
'user' => $user->user_login,
'app_password' => $password, // WordPress reveals this exactly once.
'uuid' => $item['uuid'] ?? null,
], 200 );
}
/**
* What did this server actually do with the credentials the caller sent?
*
* A device can pair perfectly and then get 401 on every single call, and from
* inside WordPress that is invisible: the two usual causes (the server never
* forwards the Authorization header to PHP, or a security plugin refuses
* Application Password authentication) both look like "not logged in". The
* mobile app calls this after an unexpected 401 so it can name the real
* problem instead of telling the user to reconnect forever.
*
* Public on purpose: every field describes the caller's own request, so this
* tells an attacker exactly what a 401 from any other endpoint already does.
*/
public function rest_auth_check( $request ) {
// It answers "were those credentials good?" with a 200, which is no more
// than any authenticated route already reveals through its 401. Still, the
// cheerful phrasing is the kind of thing a reviewer stops on, so it gets the
// same per-IP limiter as the pairing route. A client only ever calls this
// once, after a failure.
$ip = method_exists( $this->core, 'get_ip_address' ) ? $this->core->get_ip_address() : ( $_SERVER['REMOTE_ADDR'] ?? '' );
$rlKey = 'mwai_authchk_rl_' . md5( (string) $ip );
$attempts = (int) get_transient( $rlKey );
if ( $attempts >= 30 ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Too many attempts. Please wait a minute.' ], 429 );
}
set_transient( $rlKey, $attempts + 1, MINUTE_IN_SECONDS );
$header = $request instanceof WP_REST_Request ? (string) $request->get_header( 'authorization' ) : '';
// Either location counts: mod_php consumes the header into PHP_AUTH_* and
// leaves get_header() empty, which is still a server that forwards it.
$sawHeader = $header !== '' || !empty( $_SERVER['PHP_AUTH_USER'] );
$userId = get_current_user_id();
$viaAppPassword = function_exists( 'rest_get_authenticated_app_password' )
&& (bool) rest_get_authenticated_app_password();
if ( !$sawHeader ) {
// Careful with this wording: "the client sent nothing" and "the client
// sent credentials and this server stripped them" arrive here completely
// identically, so naming the server as the culprit would send users to
// their host for a bug that may be in the client. pair-check is the route
// that may be confident about it: it probes with a header it knows it sent.
$reason = 'no_authorization_header';
// The URL is inlined in the text as well as returned separately: a client
// that only renders `message` still gives the user somewhere to go.
$message = 'No credentials reached WordPress. Either the app sent none, or this server does not forward the Authorization header to PHP, which is a common host setting. ' . self::DOC_MOBILE_URL;
}
elseif ( !$userId ) {
$reason = 'credentials_rejected';
$message = 'WordPress received the credentials but refused them. The app password may have been revoked, or a security plugin may be blocking Application Passwords. ' . self::DOC_MOBILE_URL;
}
elseif ( !current_user_can( 'manage_options' ) ) {
$reason = 'not_admin';
$message = 'That account is not an administrator, and the Workspace is available to administrators only.';
}
else {
$reason = 'ok';
$message = 'Authentication is working.';
}
return new WP_REST_Response( [
'success' => true,
'reason' => $reason,
'message' => $message,
'authorization_header' => $sawHeader,
'authenticated' => $userId > 0,
'app_password' => $viaAppPassword,
'can_use_workspace' => $userId > 0 && current_user_can( 'manage_options' ),
'app_passwords_available' => $this->pairing_available( $userId ? wp_get_current_user() : null ),
'ssl' => is_ssl(),
'doc_url' => self::DOC_MOBILE_URL,
], 200 );
}
/**
* Admin-side pre-flight for the QR panel: a loopback request back to this same
* site, carrying an Authorization header, to find out whether the header
* survives the trip to PHP. When it doesn't, pairing still succeeds (that
* endpoint is public) and every call after it fails, so the admin gets warned
* before scanning rather than after three revoked devices.
*
* The probe deliberately sends a Bearer token, not Basic credentials: it is a
* question about the header, not a login attempt, and a fake Basic login would
* feed the site's own IP to brute-force counters.
*/
public function rest_pair_check( $request ) {
$response = wp_remote_get( rest_url( 'mwai/v1/workspace/auth-check' ), [
'timeout' => 10,
'headers' => [ 'Authorization' => 'Bearer mwai-header-probe' ],
'sslverify' => apply_filters( 'mwai_workspace_self_test_sslverify', true ),
] );
// A blocked or failing loopback says nothing about the header: stay quiet
// rather than warn about a problem that may not exist.
if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) {
return new WP_REST_Response( [ 'success' => true, 'status' => 'unknown' ], 200 );
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( !is_array( $body ) || !array_key_exists( 'authorization_header', $body ) ) {
return new WP_REST_Response( [ 'success' => true, 'status' => 'unknown' ], 200 );
}
if ( empty( $body['authorization_header'] ) ) {
return new WP_REST_Response( [
'success' => true,
'status' => 'header_stripped',
// No URL inline here: the admin panel renders doc_url as a real link.
'message' => 'This server does not forward the Authorization header to WordPress, so pairing will look like it worked but the app will not be able to connect.',
'doc_url' => self::DOC_MOBILE_URL,
], 200 );
}
return new WP_REST_Response( [ 'success' => true, 'status' => 'ok' ], 200 );
}
public function rest_devices( $request ) {
if ( !class_exists( 'WP_Application_Passwords' ) ) {
return new WP_REST_Response( [ 'success' => true, 'devices' => [], 'available' => false ], 200 );
}
$passwords = WP_Application_Passwords::get_user_application_passwords( get_current_user_id() );
$devices = [];
foreach ( (array) $passwords as $p ) {
if ( isset( $p['name'] ) && strpos( $p['name'], self::PAIR_APP_PREFIX ) === 0 ) {
$devices[] = [
'uuid' => $p['uuid'] ?? '',
'name' => $p['name'],
'created' => $p['created'] ?? null,
'last_used' => $p['last_used'] ?? null,
'last_ip' => $p['last_ip'] ?? null,
];
}
}
return new WP_REST_Response( [ 'success' => true, 'devices' => $devices, 'available' => $this->pairing_available( wp_get_current_user() ) ], 200 );
}
public function rest_device_revoke( $request ) {
$params = $request->get_json_params();
$uuid = isset( $params['uuid'] ) ? sanitize_text_field( (string) $params['uuid'] ) : '';
if ( $uuid === '' || !class_exists( 'WP_Application_Passwords' ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Invalid device.' ], 400 );
}
// Only revoke our own paired devices, and only the current user's.
$userId = get_current_user_id();
$target = WP_Application_Passwords::get_user_application_password( $userId, $uuid );
if ( !$target || strpos( $target['name'] ?? '', self::PAIR_APP_PREFIX ) !== 0 ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'That device was not found.' ], 404 );
}
$res = WP_Application_Passwords::delete_application_password( $userId, $uuid );
if ( is_wp_error( $res ) || !$res ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Could not revoke that device.' ], 200 );
}
return new WP_REST_Response( [ 'success' => true ], 200 );
}
#endregion
#region Images (expiry info + move to Media Library)
private function get_file_row_by_url( $url ) {
global $wpdb;
return $wpdb->get_row( $wpdb->prepare(
"SELECT * FROM {$wpdb->prefix}mwai_files WHERE url = %s",
$url
), ARRAY_A );
}
public function rest_image_info( $request ) {
$params = $request->get_json_params();
$urls = isset( $params['urls'] ) && is_array( $params['urls'] ) ? array_slice( $params['urls'], 0, 30 ) : [];
$images = [];
foreach ( $urls as $url ) {
$url = esc_url_raw( (string) $url );
if ( empty( $url ) ) {
continue;
}
$row = $this->get_file_row_by_url( $url );
if ( $row ) {
$images[$url] = [
'status' => 'file',
// The files table stores UTC (WP pins PHP to UTC).
'expires' => !empty( $row['expires'] ) ? strtotime( $row['expires'] . ' +0000' ) : null,
];
}
else {
$images[$url] = [ 'status' => attachment_url_to_postid( $url ) ? 'library' : 'unknown' ];
}
}
return new WP_REST_Response( [ 'success' => true, 'now' => time(), 'images' => $images ], 200 );
}
public function rest_image_persist( $request ) {
$params = $request->get_json_params();
$url = esc_url_raw( (string) ( $params['url'] ?? '' ) );
$chatId = sanitize_text_field( (string) ( $params['chatId'] ?? '' ) );
$row = $url ? $this->get_file_row_by_url( $url ) : null;
if ( !$row || empty( $row['path'] ) || !file_exists( $row['path'] ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'This image is not available anymore.' ], 404 );
}
$filetype = wp_check_filetype( $row['path'] );
if ( empty( $filetype['type'] ) || strpos( $filetype['type'], 'image/' ) !== 0 ) {
return new WP_REST_Response( [ 'success' => false, 'message' => 'Only images can be saved to the Media Library.' ], 400 );
}
$upload = wp_upload_bits( basename( $row['path'] ), null, file_get_contents( $row['path'] ) );
if ( !empty( $upload['error'] ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => $upload['error'] ], 500 );
}
$attachmentId = wp_insert_attachment( [
'post_mime_type' => $filetype['type'],
'post_title' => sanitize_file_name( pathinfo( $row['path'], PATHINFO_FILENAME ) ),
'post_status' => 'inherit',
], $upload['file'] );
if ( is_wp_error( $attachmentId ) ) {
return new WP_REST_Response( [ 'success' => false, 'message' => $attachmentId->get_error_message() ], 500 );
}
require_once ABSPATH . 'wp-admin/includes/image.php';
wp_update_attachment_metadata( $attachmentId, wp_generate_attachment_metadata( $attachmentId, $upload['file'] ) );
$newUrl = wp_get_attachment_url( $attachmentId );
// Rewrite the URL in the conversation history so it survives the temp
// file's expiration (and this Workspace reload shows the new URL).
if ( !empty( $chatId ) && !empty( $this->core->discussions ) ) {
$discussion = $this->core->discussions->get_discussion( self::BOT_ID, $chatId );
if ( $discussion ) {
$messages = $discussion['messages'];
foreach ( $messages as $m ) {
if ( isset( $m->content ) && is_string( $m->content ) ) {
$m->content = str_replace( $url, $newUrl, $m->content );
}
}
global $wpdb;
$wpdb->update(
$this->core->discussions->table_chats,
[ 'messages' => wp_json_encode( $messages ), 'updated' => date( 'Y-m-d H:i:s' ) ],
[ 'id' => $discussion['id'] ]
);
}
}
// Drop the temp file (record + physical) now that the attachment owns the
// image; the history no longer references the old URL.
if ( !empty( $this->core->files ) ) {
$this->core->files->delete_files( [ (int) $row['id'] ] );
}
return new WP_REST_Response( [ 'success' => true, 'url' => $newUrl, 'attachmentId' => $attachmentId ], 200 );
}
#endregion
private function get_prefs() {
$defaults = [
'theme' => 'dark', 'accent' => 'blue', 'envId' => null, 'model' => null,
'collapsed' => false, 'prompts' => [],
'pinned' => [ 'uploads', 'knowledge' ],
'knowledgeEnvId' => null, 'mcpServers' => [], 'functions' => [],
'imageMode' => false,
'webSearchMode' => false,
'wpMode' => false, 'wpCategories' => [ 'AI Engine (Core)' ],
'advanced' => [ 'temperature' => null, 'reasoningEffort' => null ],
'pinnedChats' => [],
'folders' => [],
];
$prefs = get_user_meta( get_current_user_id(), self::PREFS_META, true );
return is_array( $prefs ) ? array_merge( $defaults, $prefs ) : $defaults;
}
public function rest_prefs( $request ) {
if ( $request->get_method() === 'POST' ) {
$params = $request->get_json_params();
$prefs = $this->get_prefs();
if ( isset( $params['theme'] ) && in_array( $params['theme'], [ 'dark', 'light' ], true ) ) {
$prefs['theme'] = $params['theme'];
}
if ( isset( $params['accent'] ) ) {
$prefs['accent'] = sanitize_key( $params['accent'] );
}
if ( array_key_exists( 'envId', $params ) ) {
$prefs['envId'] = $params['envId'] ? sanitize_text_field( $params['envId'] ) : null;
}
if ( array_key_exists( 'model', $params ) ) {
$prefs['model'] = $params['model'] ? sanitize_text_field( $params['model'] ) : null;
}
if ( isset( $params['collapsed'] ) ) {
$prefs['collapsed'] = (bool) $params['collapsed'];
}
if ( isset( $params['imageMode'] ) ) {
$prefs['imageMode'] = (bool) $params['imageMode'];
}
if ( isset( $params['webSearchMode'] ) ) {
$prefs['webSearchMode'] = (bool) $params['webSearchMode'];
}
if ( isset( $params['wpMode'] ) ) {
$prefs['wpMode'] = (bool) $params['wpMode'];
}
if ( isset( $params['wpCategories'] ) && is_array( $params['wpCategories'] ) ) {
$prefs['wpCategories'] = array_values( array_filter( array_map(
'sanitize_text_field',
array_slice( $params['wpCategories'], 0, 20 )
) ) );
}
if ( isset( $params['advanced'] ) && is_array( $params['advanced'] ) ) {
$temp = $params['advanced']['temperature'] ?? null;
$effort = $params['advanced']['reasoningEffort'] ?? null;
$prefs['advanced'] = [
'temperature' => is_numeric( $temp ) ? max( 0, min( 2, (float) $temp ) ) : null,
'reasoningEffort' => in_array( $effort, [ 'low', 'medium', 'high' ], true ) ? $effort : null,
];
}
if ( isset( $params['folders'] ) && is_array( $params['folders'] ) ) {
$clean = [];
foreach ( array_slice( $params['folders'], 0, 30 ) as $f ) {
if ( !is_array( $f ) ) {
continue;
}
$name = mb_substr( sanitize_text_field( $f['name'] ?? '' ), 0, 60 );
if ( $name === '' ) {
continue;
}
$clean[] = [
'id' => sanitize_key( $f['id'] ?? uniqid( 'f' ) ),
'name' => $name,
'collapsed' => !empty( $f['collapsed'] ),
'chats' => array_values( array_filter( array_map(
'sanitize_text_field',
array_slice( (array) ( $f['chats'] ?? [] ), 0, 200 )
) ) ),
];
}
$prefs['folders'] = $clean;
}
if ( isset( $params['pinnedChats'] ) && is_array( $params['pinnedChats'] ) ) {
$prefs['pinnedChats'] = array_values( array_filter( array_map(
'sanitize_text_field',
array_slice( $params['pinnedChats'], 0, 200 )
) ) );
}
if ( array_key_exists( 'knowledgeEnvId', $params ) ) {
$prefs['knowledgeEnvId'] = $params['knowledgeEnvId'] ? sanitize_text_field( $params['knowledgeEnvId'] ) : null;
}
if ( isset( $params['pinned'] ) && is_array( $params['pinned'] ) ) {
$allowed = [ 'uploads', 'image', 'web_search', 'knowledge', 'mcp', 'functions', 'wordpress' ];
$prefs['pinned'] = array_values( array_intersect( array_map( 'sanitize_key', $params['pinned'] ), $allowed ) );
}
if ( isset( $params['mcpServers'] ) && is_array( $params['mcpServers'] ) ) {
$prefs['mcpServers'] = array_values( array_filter( array_map(
'sanitize_text_field',
array_slice( $params['mcpServers'], 0, 50 )
) ) );
}
if ( isset( $params['functions'] ) && is_array( $params['functions'] ) ) {
$clean = [];
foreach ( array_slice( $params['functions'], 0, 100 ) as $f ) {
if ( is_array( $f ) && !empty( $f['id'] ) && !empty( $f['type'] ) ) {
$clean[] = [ 'type' => sanitize_text_field( $f['type'] ), 'id' => sanitize_text_field( $f['id'] ) ];
}
}
$prefs['functions'] = $clean;
}
if ( isset( $params['prompts'] ) && is_array( $params['prompts'] ) ) {
$clean = [];
foreach ( array_slice( $params['prompts'], 0, 200 ) as $p ) {
if ( !is_array( $p ) ) {
continue;
}
$title = mb_substr( sanitize_text_field( $p['title'] ?? '' ), 0, 200 );
$content = mb_substr( sanitize_textarea_field( $p['content'] ?? '' ), 0, 20000 );
if ( $title === '' && $content === '' ) {
continue;
}
$clean[] = [
'id' => sanitize_key( $p['id'] ?? uniqid( 'p' ) ),
'title' => $title,
'content' => $content,
];
}
$prefs['prompts'] = $clean;
}
update_user_meta( get_current_user_id(), self::PREFS_META, $prefs );
return new WP_REST_Response( [ 'success' => true, 'prefs' => $prefs ], 200 );
}
return new WP_REST_Response( [ 'success' => true, 'prefs' => $this->get_prefs() ], 200 );
}
#endregion
}