Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: changed

AI Answers: Follow the Jetpack AI master switch in the Search dashboard and the AI Answer block, in internal testing environments ahead of release.
6 changes: 6 additions & 0 deletions projects/packages/search/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ module.exports = {
'tiny-lru/lib/tiny-lru.esm$': '<rootDir>/src/instant-search/lib/test-helpers/tiny-lru.mock.js',
'instant-search/components/gridicon':
'<rootDir>/src/instant-search/components/gridicon/index.jsx',
// Mirror the customberg webpack resolution (tools/webpack.customberg.config.js):
// `instant-search` is an alias and `hooks/*` resolves against src/customberg.
// Exact-name hook entries so dashboard's own `hooks/*` resolution is untouched.
'^instant-search/(.*)$': '<rootDir>/src/instant-search/$1',
'^hooks/use-search-options$': '<rootDir>/src/customberg/hooks/use-search-options.js',
'^hooks/use-loading-state$': '<rootDir>/src/customberg/hooks/use-loading-state.js',
},
moduleDirectories: [ 'node_modules', '<rootDir>/src/dashboard' ],
setupFilesAfterEnv: [ ...baseConfig.setupFilesAfterEnv, '<rootDir>/tests/jest-globals.gui.js' ],
Expand Down
65 changes: 61 additions & 4 deletions projects/packages/search/src/class-ai-answers.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,19 @@

namespace Automattic\Jetpack\Search;

use Automattic\Jetpack\Modules;
use Automattic\Jetpack\Status\Host;

/**
* Registers behavior meta on the Gutenberg Guidelines CPT and exposes the
* jetpack_search_ai_answers_enabled option.
*/
class AI_Answers {
const BEHAVIOR_META_KEY = '_guideline_block_jetpack_search-ai-summary';
const BEHAVIOR_OPTION_KEY = 'jetpack_search_ai_behavior_instructions';
const AI_MODULE = 'ai';
const AI_MASTER_OPTION = 'jetpack_ai_enabled';
const ENABLED_OPTION = 'jetpack_search_ai_answers_enabled';

/**
* Hook up meta/setting registration.
Expand Down Expand Up @@ -84,13 +90,64 @@ public static function get_behavior_instructions() {
return (string) get_option( self::BEHAVIOR_OPTION_KEY, '' );
}

/**
* Whether the site-wide Jetpack AI master switch is on.
*
* Mirrors `Jetpack_AI_Settings::is_master_enabled()` in the Jetpack plugin —
* the source of truth, unreferenceable from standalone installs. Computed
* rather than filtered so no plugin can flip a gate that must hold.
*
* @since $$next-version$$
*
* @return bool True when Jetpack AI is on, or when the site has no master switch.
*/
public static function is_master_enabled() {
// The master's own UI is internal-only, so never gate a public site on a
// switch its owner cannot see: outside internal testing environments the
// gate is inert. This scoping comes off at release.
if ( ! function_exists( 'jetpack_is_internal_testing_environment' ) || ! jetpack_is_internal_testing_environment() ) {
return true;
}

if ( ( new Host() )->is_wpcom_simple() ) {
return (bool) get_option( self::AI_MASTER_OPTION, true );
}

$modules = new Modules();

// Without the Jetpack plugin — a standalone Jetpack Search install — the
// `ai` module is not registered, so is_active() would report false for a
// master switch that was never installed. Don't gate those sites.
if ( ! in_array( self::AI_MODULE, $modules->get_available(), true ) ) {
return true;
}

// Availability is already proven above, so skip is_active()'s repeat intersect.
return $modules->is_active( self::AI_MODULE, false );
}

/**
* The stored AI Answers choice, ignoring every gate.
*
* The dashboard shows this while the master switch is off, so a saved choice
* isn't misreported back to the user as off.
*
* @since $$next-version$$
*
* @return bool
*/
public static function is_saved_on() {
return (bool) get_option( self::ENABLED_OPTION, false );
}

/**
* Whether AI Answers is enabled for the current site.
*/
public static function is_enabled() {
return (bool) apply_filters(
'jetpack_search_ai_answers_enabled',
(bool) get_option( 'jetpack_search_ai_answers_enabled', false )
);
$enabled = (bool) apply_filters( 'jetpack_search_ai_answers_enabled', self::is_saved_on() );

// The master gate is applied after the filter chain so it cannot be
// filtered back on, matching `Jetpack_AI_Settings::is_ai_enabled()`.
return $enabled && self::is_master_enabled();
}
}
1 change: 1 addition & 0 deletions projects/packages/search/src/class-helper.php
Original file line number Diff line number Diff line change
Expand Up @@ -992,6 +992,7 @@ public static function generate_initial_javascript_state() {
*/
'disableTracking' => self::is_tracking_disabled() || apply_filters( 'jetpack_instant_search_disable_tracking', false ),
'aiAnswersEnabled' => AI_Answers::is_enabled(),
'aiMasterEnabled' => AI_Answers::is_master_enabled(),
);

/**
Expand Down
12 changes: 12 additions & 0 deletions projects/packages/search/src/class-rest-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -388,6 +388,16 @@ protected function validate_search_settings( $module_active, $instant_search_ena
);
}

// AI Answers cannot be turned on while the site-wide Jetpack AI switch is
// off. Turning it off stays allowed, so a saved choice can still be cleared.
if ( true === $ai_answers_enabled && ! AI_Answers::is_master_enabled() ) {
return new WP_Error(
'rest_invalid_arguments',
esc_html__( 'AI Answers cannot be enabled while Jetpack AI is turned off for this site.', 'jetpack-search-pkg' ),
array( 'status' => 400 )
);
}

// `experience` is the canonical source of truth and writes the legacy booleans in lockstep.
// Reject requests that mix it with any other settings field so callers don't silently
// lose those fields — the `experience` branch in update_settings() early-returns and
Expand Down Expand Up @@ -442,6 +452,8 @@ public function get_settings() {
'swap_classic_to_inline_search' => $this->search_module->is_swap_classic_to_inline_search(),
'experience' => $this->search_module->get_experience(),
'ai_answers_enabled' => AI_Answers::is_enabled(),
'ai_answers_saved' => AI_Answers::is_saved_on(),
'ai_master_enabled' => AI_Answers::is_master_enabled(),
'search_suggestions_enabled' => (bool) get_option( 'jetpack_search_suggestions_enabled', false ),
'override_woocommerce_search_template' => Search_Blocks::woocommerce_search_template_override_enabled(),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,14 @@ export default function AppWrapper() {
};

// aiAnswersEnabled + searchSuggestionsEnabled live at the top level of the
// instant-search options object (not under `overlayOptions`). Override them
// here so the preview reacts to the sidebar toggles without a save round-trip.
// options object; overridden here so the preview reacts to the sidebar. While
// the master is off a saved choice persists unenforced — preview gets false.
const { aiMasterEnabled = true } = window[ SERVER_OBJECT_NAME ];
const options = {
...window[ SERVER_OBJECT_NAME ],
...Object.fromEntries(
Object.entries( {
aiAnswersEnabled,
aiAnswersEnabled: aiMasterEnabled ? aiAnswersEnabled : false,
searchSuggestionsEnabled,
} ).filter( ( [ , v ] ) => typeof v !== 'undefined' )
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import ColorControl from './color-control';
import ExcludedPostTypesControl from './excluded-post-types-control';
import ThemeControl from './theme-control';

const { isFreePlan = false } = window[ SERVER_OBJECT_NAME ];
const { isFreePlan = false, aiMasterEnabled = true } = window[ SERVER_OBJECT_NAME ];

/**
* Customization/configuration tab for the sidebar.
Expand Down Expand Up @@ -60,6 +60,17 @@ export default function SidebarOptions() {
const { isLoading } = useSiteLoadingState();
const isDisabled = isSaving || isLoading;

const aiAnswersHelp = aiMasterEnabled
? __(
'Generate AI-powered answers to visitor queries using your site’s content.',
'jetpack-search-pkg'
)
: __(
'Jetpack AI is turned off for this site. Your setting will apply again when AI is turned back on.',
'jetpack-search-pkg',
/* dummy arg to avoid bad minification */ 0
);

const sortOptions = [
{ label: __( 'Relevance (recommended)', 'jetpack-search-pkg' ), value: 'relevance' },
{ label: __( 'Newest first', 'jetpack-search-pkg' ), value: 'newest' },
Expand Down Expand Up @@ -212,12 +223,9 @@ export default function SidebarOptions() {
<ToggleControl
className="jp-search-configure-ai-answers-toggle"
checked={ aiAnswersEnabled }
disabled={ isDisabled }
disabled={ isDisabled || ! aiMasterEnabled }
label={ __( 'Enable AI Answers', 'jetpack-search-pkg' ) }
help={ __(
'Generate AI-powered answers to visitor queries using your site’s content.',
'jetpack-search-pkg'
) }
help={ aiAnswersHelp }
onChange={ setAiAnswersEnabled }
__nextHasNoMarginBottom={ true }
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { render } from '@testing-library/react';
import useSearchOptions from 'hooks/use-search-options';
import SearchApp from 'instant-search/components/search-app';

jest.mock( 'instant-search/components/search-app', () => jest.fn( () => null ) );
jest.mock( 'hooks/use-search-options', () => jest.fn( () => ( {} ) ) );
jest.mock( 'hooks/use-loading-state', () => jest.fn( () => ( { isLoading: false } ) ) );
jest.mock( 'instant-search/store', () => ( {
__esModule: true,
default: { subscribe: jest.fn(), dispatch: jest.fn(), getState: () => ( {} ) },
} ) );
jest.mock( 'instant-search/lib/api', () => ( { buildFilterAggregations: () => ( {} ) } ) );
jest.mock( 'instant-search/lib/dom', () => ( { getThemeOptions: () => ( {} ) } ) );

const makeServerObject = ( overrides = {} ) => ( {
webpackPublicPath: '/',
widgets: [],
widgetsOutsideOverlay: [],
overlayOptions: {},
aiAnswersEnabled: false,
...overrides,
} );

describe( 'AppWrapper AI Answers preview gating', () => {
let AppWrapper;

const searchAppOptions = () => SearchApp.mock.calls.at( -1 )[ 0 ].options;

beforeAll( () => {
// The module reads the server object at import time, so seed it first.
window.JetpackInstantSearchOptions = makeServerObject();
AppWrapper = require( '../app-wrapper' ).default;
} );

beforeEach( () => {
SearchApp.mockClear();
} );

it( 'feeds the preview the enforced value while the master is off', () => {
// A saved-on choice persists while the master is off, so the raw
// entity value can be true — the preview must not stream on it.
window.JetpackInstantSearchOptions = makeServerObject( { aiMasterEnabled: false } );
useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } );

render( <AppWrapper /> );

expect( searchAppOptions().aiAnswersEnabled ).toBe( false );
} );

it( 'lets the sidebar toggle drive the preview while the master is on', () => {
window.JetpackInstantSearchOptions = makeServerObject( { aiMasterEnabled: true } );
useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } );

render( <AppWrapper /> );

expect( searchAppOptions().aiAnswersEnabled ).toBe( true );
} );

it( 'defaults to ungated when the server object predates the master flag', () => {
window.JetpackInstantSearchOptions = makeServerObject();
useSearchOptions.mockReturnValue( { aiAnswersEnabled: true } );

render( <AppWrapper /> );

expect( searchAppOptions().aiAnswersEnabled ).toBe( true );
} );

it( 'falls back to the server value when the sidebar has no local edit', () => {
window.JetpackInstantSearchOptions = makeServerObject( {
aiMasterEnabled: true,
aiAnswersEnabled: true,
} );
useSearchOptions.mockReturnValue( {} );

render( <AppWrapper /> );

expect( searchAppOptions().aiAnswersEnabled ).toBe( true );
} );
} );
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,24 @@ export default function AiAnswersTab() {
const blogID = useSelect( select => select( STORE_ID ).getBlogId(), [] );
const siteAdminUrl = useSelect( select => select( STORE_ID ).getSiteAdminUrl(), [] );

const { isAiAnswersEnabled, isInstantSearchEnabled, setAiAnswersEnabled } = useSearchSettings();
const {
isAiAnswersEnabled,
isInstantSearchEnabled,
isAiMasterEnabled,
isAiAnswersSaved,
setAiAnswersEnabled,
} = useSearchSettings();

// While the master switch is off the reported value is gated to false, so show
// the saved choice instead of misreporting it back as off.
const isToggleChecked = isAiMasterEnabled ? isAiAnswersEnabled : isAiAnswersSaved;

// Locked while the master is off, matching the AI Features view: the saved
// choice is shown but not editable. With the master on but Instant Search
// off, the toggle stays usable only to turn an already-enabled feature off.
const isToggleDisabled =
! isAiMasterEnabled || ( ! isInstantSearchEnabled && ! isAiAnswersEnabled );
const isAiAnswersActive = isAiMasterEnabled && isAiAnswersEnabled && isInstantSearchEnabled;

const { run: sendToCart } = useProductCheckoutWorkflow( {
productSlug: 'jetpack_search',
Expand Down Expand Up @@ -94,7 +111,20 @@ export default function AiAnswersTab() {
className="jp-search-ai-answers-tab__settings-inner lg-col-span-8 md-col-span-6 sm-col-span-4"
>
{ isLoading && <p>{ __( 'Loading…', 'jetpack-search-pkg' ) }</p> }
{ supportsInstantSearch && ! isInstantSearchEnabled && (
{ supportsInstantSearch && ! isAiMasterEnabled && (
<Notice.Root intent="warning">
<Notice.Title>
{ __( 'Jetpack AI is turned off for this site.', 'jetpack-search-pkg' ) }
</Notice.Title>
<Notice.Description>
{ __(
'Your AI Answers setting is saved and will apply again when AI is turned back on.',
'jetpack-search-pkg'
) }
</Notice.Description>
</Notice.Root>
) }
{ supportsInstantSearch && isAiMasterEnabled && ! isInstantSearchEnabled && (
<Notice.Root intent="warning">
<Notice.Title>
{ __(
Expand All @@ -109,10 +139,10 @@ export default function AiAnswersTab() {
) }
<ToggleControl
label={ __( 'Enable AI Answers', 'jetpack-search-pkg' ) }
checked={ isAiAnswersEnabled }
checked={ isToggleChecked }
onChange={ setAiAnswersEnabled }
className="jp-search-dashboard-toggle lg-col-span-12 md-col-span-8 sm-col-span-4"
disabled={ ! isInstantSearchEnabled && ! isAiAnswersEnabled }
disabled={ isToggleDisabled }
/>

{ ! isLoading && ! isUnavailable && (
Expand All @@ -124,13 +154,13 @@ export default function AiAnswersTab() {
onChange={ setContent }
placeholder={ DEFAULT_PERSONALITY }
rows={ 10 }
disabled={ isSaving || ! isAiAnswersEnabled || ! isInstantSearchEnabled }
disabled={ isSaving || ! isAiAnswersActive }
/>
<div className="jp-search-ai-answers-tab__actions">
<Button
variant="solid"
onClick={ savePersonality }
disabled={ isSaving || ! isAiAnswersEnabled || ! isInstantSearchEnabled }
disabled={ isSaving || ! isAiAnswersActive }
>
{ isSaving ? savingLabel : saveLabel }
</Button>
Expand Down
Loading
Loading