| @@ -1,13 +1,17 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | - * Desktop Mode — AI Copilot settings helpers. | |
| 3 | + * OpenStation — AI Copilot settings + capability helpers. | |
| 4 | 4 | * |
| 5 | - * Thin wrappers around `desktop_mode_get_os_settings()` scoped to the AI block. | |
| 6 | - * All other copilot modules call these instead of reading user meta | |
| 7 | - * directly so the key path is one place to change. | |
| 5 | + * The Copilot no longer stores credentials of its own. WordPress 7.0 owns | |
| 6 | + * provider credentials (Settings → Connectors) and model routing | |
| 7 | + * (`wp_ai_client_prompt()`, which injects the configured key automatically). | |
| 8 | + * These helpers therefore only carry the per-user "AI assistant" toggle | |
| 9 | + * (`ai.enabled`) and expose the Core capability signals the shell uses to | |
| 10 | + * decide whether to surface the assistant at all. Provider + model selection | |
| 11 | + * is delegated entirely to the Core AI Client — nothing is persisted here. | |
| 8 | 12 | * |
| 9 | - * @package WPDesktopMode | |
| 13 | + * @package OpenStation | |
| 10 | 14 | */ |
| 11 | 15 | |
| 12 | 16 | defined( 'ABSPATH' ) || exit; |
| 13 | 17 | |
| @@ -13,116 +17,208 @@ | ||
| 13 | 17 | |
| 14 | 18 | /** |
| 15 | 19 | * Returns the AI settings block for a given user. |
| 16 | 20 | * |
| 17 | - * @since 0.14.0 | |
| 21 | + * `enabled` defaults to `false`: the assistant is opt-in, turned on from | |
| 22 | + * OS Settings → Features (and only enable-able once a provider is configured). | |
| 23 | + * Provider + model selection is left entirely to the Core AI Client. | |
| 18 | 24 | * |
| 19 | 25 | * @param int $user_id |
| 20 | - * @return array{ enabled: bool, provider: string, apiKey: string } | |
| 26 | + * @return array{ enabled: bool } | |
| 21 | 27 | */ |
| 22 | -function desktop_mode_ai_get_settings( $user_id ) { | |
| 23 | - $os = desktop_mode_get_os_settings( (int) $user_id ); | |
| 24 | - $defaults = array( | |
| 25 | - 'enabled' => false, | |
| 26 | - 'provider' => 'openai', | |
| 27 | - 'apiKey' => '', | |
| 28 | - 'apiKeys' => array(), | |
| 28 | +function openstation_ai_get_settings( $user_id ) { | |
| 29 | + $os = openstation_get_os_settings( (int) $user_id ); | |
| 30 | + $ai = isset( $os['ai'] ) && is_array( $os['ai'] ) ? $os['ai'] : array(); | |
| 31 | + return array( | |
| 32 | + 'enabled' => isset( $ai['enabled'] ) ? (bool) $ai['enabled'] : false, | |
| 29 | 33 | ); |
| 30 | - $ai = isset( $os['ai'] ) && is_array( $os['ai'] ) ? $os['ai'] : array(); | |
| 31 | - return array_merge( $defaults, $ai ); | |
| 32 | 34 | } |
| 33 | 35 | |
| 34 | 36 | /** |
| 35 | - * Whether AI processing is active — checks platform settings first, | |
| 36 | - * then falls back to the user's personal settings. | |
| 37 | + * Whether the Core AI primitives the Copilot depends on are present. | |
| 37 | 38 | * |
| 38 | - * Priority: | |
| 39 | - * 1. Platform-wide settings (wp_options) — enabled by any admin. | |
| 40 | - * 2. Per-user settings (user meta) — personal override. | |
| 39 | + * The assistant is built on the AI Client (generation), the Connectors API | |
| 40 | + * (credentials) and the Abilities API (tools, adopted in a follow-up). When | |
| 41 | + * any of these is missing — e.g. WordPress < 7.0, or AI disabled site-wide | |
| 42 | + * via `wp_supports_ai()` — the shell hides the assistant entirely. | |
| 41 | 43 | * |
| 42 | - * @since 0.14.0 | |
| 44 | + * @return bool | |
| 45 | + */ | |
| 46 | +function openstation_ai_is_available() { | |
| 47 | + return function_exists( 'wp_ai_client_prompt' ) | |
| 48 | + && function_exists( 'wp_get_connectors' ) | |
| 49 | + && function_exists( 'wp_register_ability' ) | |
| 50 | + && function_exists( 'wp_supports_ai' ) | |
| 51 | + && wp_supports_ai(); | |
| 52 | +} | |
| 53 | + | |
| 54 | +/** | |
| 55 | + * Whether a text-generation provider is configured and usable. | |
| 43 | 56 | * |
| 44 | - * @param int $user_id | |
| 57 | + * The baseline capability gate: a no-network, deterministic | |
| 58 | + * `is_supported_for_text_generation()` probe against the AI Client registry | |
| 59 | + * (which Core populates from the configured Connectors). This is what plain | |
| 60 | + * text-generation features — the on-demand comment analysis ability, which | |
| 61 | + * only needs structured text output — gate on. The agentic assistant needs more; see | |
| 62 | + * {@see openstation_ai_assistant_provider_configured()}. | |
| 63 | + * | |
| 64 | + * Credentials are supplied by Core from the configured Connector; no API request | |
| 65 | + * is made. | |
| 66 | + * | |
| 45 | 67 | * @return bool |
| 46 | 68 | */ |
| 47 | -function desktop_mode_ai_is_enabled( $user_id ) { | |
| 48 | - $user_id = (int) $user_id; | |
| 49 | - $platform = desktop_mode_ai_get_platform_settings(); | |
| 50 | - $active = function_exists( 'desktop_mode_ai_get_active_provider_id' ) | |
| 51 | - ? desktop_mode_ai_get_active_provider_id( $user_id ) | |
| 52 | - : 'openai'; | |
| 53 | - | |
| 54 | - // 1. Platform key — works for any user, including anonymous contexts. | |
| 55 | - // Resolve via the per-provider map first, then fall back to the | |
| 56 | - // legacy single `apiKey` field (which is treated as the openai key). | |
| 57 | - if ( ! empty( $platform['enabled'] ) && '' !== desktop_mode_ai_resolve_key_for_provider( $platform, $active ) ) { | |
| 58 | - return true; | |
| 69 | +function openstation_ai_provider_configured() { | |
| 70 | + if ( ! openstation_ai_is_available() ) { | |
| 71 | + return false; | |
| 59 | 72 | } |
| 73 | + return (bool) wp_ai_client_prompt( 'test' )->is_supported_for_text_generation(); | |
| 74 | +} | |
| 60 | 75 | |
| 61 | - // 2. Per-user override. | |
| 62 | - $ai = desktop_mode_ai_get_settings( $user_id ); | |
| 63 | - if ( empty( $ai['enabled'] ) ) { | |
| 76 | +/** | |
| 77 | + * Whether a provider that can actually run the agentic assistant is configured. | |
| 78 | + * | |
| 79 | + * Follows the AI Client feature-detection guidance: rather than probing a bare | |
| 80 | + * builder, we configure it the way the assistant actually calls the client — | |
| 81 | + * with a function declaration — before the (no-network, deterministic) | |
| 82 | + * `is_supported_for_text_generation()` check. `ModelRequirements::fromPromptData()` | |
| 83 | + * turns the attached declaration into a `functionDeclarations` requirement, so | |
| 84 | + * the check passes only when an available model supports text generation *and* | |
| 85 | + * function calling — the two capabilities the tool loop depends on. | |
| 86 | + * | |
| 87 | + * Falls back to the plain text-generation gate when the SDK's FunctionDeclaration | |
| 88 | + * class isn't present (e.g. older WordPress). | |
| 89 | + * | |
| 90 | + * @return bool | |
| 91 | + */ | |
| 92 | +function openstation_ai_assistant_provider_configured() { | |
| 93 | + if ( ! openstation_ai_is_available() ) { | |
| 64 | 94 | return false; |
| 65 | 95 | } |
| 66 | - if ( '' === desktop_mode_ai_resolve_key_for_provider( $ai, $active ) ) { | |
| 67 | - return false; | |
| 96 | + | |
| 97 | + $probe = openstation_ai_capability_probe_declaration(); | |
| 98 | + if ( ! $probe ) { | |
| 99 | + return openstation_ai_provider_configured(); | |
| 68 | 100 | } |
| 69 | 101 | |
| 70 | - return true; | |
| 102 | + return (bool) wp_ai_client_prompt( 'test' ) | |
| 103 | + ->using_function_declarations( $probe ) | |
| 104 | + ->is_supported_for_text_generation(); | |
| 71 | 105 | } |
| 72 | 106 | |
| 73 | 107 | /** |
| 74 | - * Resolve which key — from a settings block — applies to a given provider. | |
| 108 | + * Builds a throwaway function declaration used only for capability detection. | |
| 75 | 109 | * |
| 76 | - * Order: explicit `apiKeys[provider]` → legacy `apiKey` (only when the | |
| 77 | - * provider is `openai`, since that's what the legacy field meant) → ''. | |
| 110 | + * Mirrors the shape of the Copilot's real tool declarations so the AI Client's | |
| 111 | + * support check additionally requires function-calling capability. Returns null | |
| 112 | + * when the SDK class isn't available, letting the caller fall back to a plain | |
| 113 | + * text-generation check. | |
| 78 | 114 | * |
| 79 | - * @since 0.18.0 | |
| 80 | - * | |
| 81 | - * @param array $settings `apiKeys` and `apiKey` carrying settings block. | |
| 82 | - * @param string $provider_id Provider id to resolve for. | |
| 83 | - * @return string Trimmed API key, or '' if none. | |
| 115 | + * @return object|null A `FunctionDeclaration`, or null. | |
| 84 | 116 | */ |
| 85 | -function desktop_mode_ai_resolve_key_for_provider( array $settings, $provider_id ) { | |
| 86 | - $provider_id = (string) $provider_id; | |
| 87 | - $keys = isset( $settings['apiKeys'] ) && is_array( $settings['apiKeys'] ) ? $settings['apiKeys'] : array(); | |
| 88 | - | |
| 89 | - if ( isset( $keys[ $provider_id ] ) && is_string( $keys[ $provider_id ] ) && '' !== $keys[ $provider_id ] ) { | |
| 90 | - return (string) $keys[ $provider_id ]; | |
| 117 | +function openstation_ai_capability_probe_declaration() { | |
| 118 | + $class = '\WordPress\AiClient\Tools\DTO\FunctionDeclaration'; | |
| 119 | + if ( ! class_exists( $class ) ) { | |
| 120 | + return null; | |
| 91 | 121 | } |
| 92 | - | |
| 93 | - if ( 'openai' === $provider_id && isset( $settings['apiKey'] ) && is_string( $settings['apiKey'] ) && '' !== $settings['apiKey'] ) { | |
| 94 | - return (string) $settings['apiKey']; | |
| 122 | + try { | |
| 123 | + return new $class( | |
| 124 | + 'capability_probe', | |
| 125 | + 'Feature-detection probe; never invoked.', | |
| 126 | + null | |
| 127 | + ); | |
| 128 | + } catch ( \Throwable $e ) { | |
| 129 | + return null; | |
| 95 | 130 | } |
| 131 | +} | |
| 96 | 132 | |
| 97 | - return ''; | |
| 133 | +/** | |
| 134 | + * Whether the AI assistant is active for a given user. | |
| 135 | + * | |
| 136 | + * This is purely the per-user toggle (default off, opt-in). Availability of the Core | |
| 137 | + * APIs and whether a provider key is set are separate, orthogonal checks | |
| 138 | + * ({@see openstation_ai_is_available()} / {@see openstation_ai_provider_configured()}) | |
| 139 | + * so callers can distinguish "user turned it off" from "not set up yet". | |
| 140 | + * | |
| 141 | + * @param int $user_id | |
| 142 | + * @return bool | |
| 143 | + */ | |
| 144 | +function openstation_ai_is_enabled( $user_id ) { | |
| 145 | + $ai = openstation_ai_get_settings( (int) $user_id ); | |
| 146 | + return ! empty( $ai['enabled'] ); | |
| 98 | 147 | } |
| 99 | 148 | |
| 100 | 149 | /** |
| 101 | - * Returns the API key to use for a given user. | |
| 150 | + * Builds the `aiAssistant` shell-config payload for a user. | |
| 102 | 151 | * |
| 103 | - * Per-user key takes precedence (personal override); falls back to the | |
| 104 | - * platform-wide key so anonymous contexts (cron, WP-CLI, anonymous | |
| 105 | - * comments) always have a key available when the admin has configured one. | |
| 152 | + * The client uses this to decide whether to surface the Cmd+K assistant and | |
| 153 | + * its admin-bar icon at all (`available`) and the user's own on/off toggle | |
| 154 | + * (`enabled`). Two capability gates are reported separately: | |
| 106 | 155 | * |
| 107 | - * @since 0.14.0 | |
| 156 | + * - `assistantProviderConfigured` — a provider that supports text generation | |
| 157 | + * *and* function calling (what the agentic assistant needs). Gates the Cmd+K | |
| 158 | + * assistant, its admin-bar icon, and the "AI assistant" toggle in Features. | |
| 159 | + * - `providerConfigured` — the baseline text-generation gate, for the calls | |
| 160 | + * that need text output but not function calling (the Drafts writing | |
| 161 | + * assistant, the on-demand comment analysis ability). | |
| 108 | 162 | * |
| 109 | - * @param int $user_id | |
| 110 | - * @return string API key, or empty string if none configured. | |
| 163 | + * Provider + model selection is delegated to the Core AI Client, so there is no | |
| 164 | + * per-user preference to carry here. | |
| 165 | + * | |
| 166 | + * @param int|null $user_id Defaults to the current user. | |
| 167 | + * @return array{ available: bool, providerConfigured: bool, assistantProviderConfigured: bool, enabled: bool, connectorsUrl: string } | |
| 111 | 168 | */ |
| 112 | -function desktop_mode_ai_get_api_key( $user_id ) { | |
| 113 | - $user_id = (int) $user_id; | |
| 114 | - $active = function_exists( 'desktop_mode_ai_get_active_provider_id' ) | |
| 115 | - ? desktop_mode_ai_get_active_provider_id( $user_id ) | |
| 116 | - : 'openai'; | |
| 169 | +function openstation_ai_assistant_config( $user_id = null ) { | |
| 170 | + $user_id = null === $user_id ? get_current_user_id() : (int) $user_id; | |
| 117 | 171 | |
| 118 | - // Per-user override takes precedence. | |
| 119 | - $ai = desktop_mode_ai_get_settings( $user_id ); | |
| 120 | - $key = desktop_mode_ai_resolve_key_for_provider( $ai, $active ); | |
| 121 | - if ( '' !== $key ) { | |
| 122 | - return $key; | |
| 172 | + $connectors_url = admin_url( 'options-connectors.php' ); | |
| 173 | + | |
| 174 | + if ( ! openstation_ai_is_available() ) { | |
| 175 | + return array( | |
| 176 | + 'available' => false, | |
| 177 | + 'providerConfigured' => false, | |
| 178 | + 'assistantProviderConfigured' => false, | |
| 179 | + 'enabled' => false, | |
| 180 | + 'connectorsUrl' => $connectors_url, | |
| 181 | + ); | |
| 123 | 182 | } |
| 124 | 183 | |
| 125 | - // Fall back to platform key. | |
| 126 | - $platform = desktop_mode_ai_get_platform_settings(); | |
| 127 | - return desktop_mode_ai_resolve_key_for_provider( $platform, $active ); | |
| 184 | + $ai = openstation_ai_get_settings( $user_id ); | |
| 185 | + | |
| 186 | + return array( | |
| 187 | + 'available' => true, | |
| 188 | + 'providerConfigured' => openstation_ai_provider_configured(), | |
| 189 | + 'assistantProviderConfigured' => openstation_ai_assistant_provider_configured(), | |
| 190 | + 'enabled' => (bool) $ai['enabled'], | |
| 191 | + 'connectorsUrl' => $connectors_url, | |
| 192 | + ); | |
| 193 | +} | |
| 194 | + | |
| 195 | +/** | |
| 196 | + * REST: GET `desktop-mode/v1/ai/status`. | |
| 197 | + * | |
| 198 | + * Returns the current {@see openstation_ai_assistant_config()} so the shell | |
| 199 | + * can re-check provider availability without a page reload — e.g. after the | |
| 200 | + * user configures an AI provider in Settings → Connectors. | |
| 201 | + */ | |
| 202 | +function openstation_register_ai_status_rest_route() { | |
| 203 | + register_rest_route( | |
| 204 | + 'desktop-mode/v1', | |
| 205 | + '/ai/status', | |
| 206 | + array( | |
| 207 | + 'methods' => WP_REST_Server::READABLE, | |
| 208 | + 'callback' => 'openstation_rest_ai_status', | |
| 209 | + 'permission_callback' => static function () { | |
| 210 | + return is_user_logged_in() && current_user_can( 'read' ); | |
| 211 | + }, | |
| 212 | + ) | |
| 213 | + ); | |
| 214 | +} | |
| 215 | +add_action( 'rest_api_init', 'openstation_register_ai_status_rest_route' ); | |
| 216 | + | |
| 217 | +/** | |
| 218 | + * REST handler for the AI status probe. | |
| 219 | + * | |
| 220 | + * @return WP_REST_Response | |
| 221 | + */ | |
| 222 | +function openstation_rest_ai_status() { | |
| 223 | + return rest_ensure_response( openstation_ai_assistant_config() ); | |
| 128 | 224 | } |