From 81be83e5dcbe7123270c52a737ea1a00119c03cb Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 15 Oct 2024 16:35:54 -0600 Subject: [PATCH 1/6] Initial implementation of the Chrome AI API with the excerpt generation feature --- .../Classifai/Features/ExcerptGeneration.php | 2 + .../Classifai/Providers/Browser/ChromeAI.php | 299 ++++++++++++++++++ .../Classifai/Services/LanguageProcessing.php | 1 + src/js/features/excerpt-generation/panel.js | 19 +- 4 files changed, 319 insertions(+), 2 deletions(-) create mode 100644 includes/Classifai/Providers/Browser/ChromeAI.php diff --git a/includes/Classifai/Features/ExcerptGeneration.php b/includes/Classifai/Features/ExcerptGeneration.php index 7ab110922..63a388e84 100644 --- a/includes/Classifai/Features/ExcerptGeneration.php +++ b/includes/Classifai/Features/ExcerptGeneration.php @@ -6,6 +6,7 @@ use Classifai\Providers\GoogleAI\GeminiAPI; use Classifai\Providers\OpenAI\ChatGPT; use Classifai\Providers\Azure\OpenAI; +use Classifai\Providers\Browser\ChromeAI; use WP_REST_Server; use WP_REST_Request; use WP_Error; @@ -45,6 +46,7 @@ public function __construct() { ChatGPT::ID => __( 'OpenAI ChatGPT', 'classifai' ), GeminiAPI::ID => __( 'Google AI (Gemini API)', 'classifai' ), OpenAI::ID => __( 'Azure OpenAI', 'classifai' ), + ChromeAI::ID => __( 'Chrome AI', 'classifai' ), // TODO: only add this Provider if the browser supports AI. ]; } diff --git a/includes/Classifai/Providers/Browser/ChromeAI.php b/includes/Classifai/Providers/Browser/ChromeAI.php new file mode 100644 index 000000000..489cacce4 --- /dev/null +++ b/includes/Classifai/Providers/Browser/ChromeAI.php @@ -0,0 +1,299 @@ +feature_instance = $feature_instance; + } + + /** + * Register what we need for the plugin. + */ + public function register() { + // TODO: find a better hook for this. + add_action( 'wp_print_scripts', [ $this, 'output_excerpt_script' ] ); + } + + /** + * Render the provider fields. + */ + public function render_provider_fields() { + $settings = $this->feature_instance->get_settings( static::ID ); + + switch ( $this->feature_instance::ID ) { + case ContentResizing::ID: + case TitleGeneration::ID: + add_settings_field( + static::ID . '_number_of_suggestions', + esc_html__( 'Number of suggestions', 'classifai' ), + [ $this->feature_instance, 'render_input' ], + $this->feature_instance->get_option_name(), + $this->feature_instance->get_option_name() . '_section', + [ + 'option_index' => static::ID, + 'label_for' => 'number_of_suggestions', + 'input_type' => 'number', + 'min' => 1, + 'step' => 1, + 'default_value' => $settings['number_of_suggestions'], + 'class' => 'classifai-provider-field hidden provider-scope-' . static::ID, + 'description' => esc_html__( 'Number of suggestions that will be generated in one request.', 'classifai' ), + ] + ); + break; + } + + do_action( 'classifai_' . static::ID . '_render_provider_fields', $this ); + } + + /** + * Returns the default settings for this provider. + * + * @return array + */ + public function get_default_provider_settings(): array { + $common_settings = [ + 'authenticated' => true, + ]; + + /** + * Default values for feature specific settings. + */ + switch ( $this->feature_instance::ID ) { + case ContentResizing::ID: + case TitleGeneration::ID: + $common_settings['number_of_suggestions'] = 1; + break; + } + + return $common_settings; + } + + /** + * Sanitize the settings for this provider. + * + * @param array $new_settings The settings array. + * @return array + */ + public function sanitize_settings( array $new_settings ): array { + $settings = $this->feature_instance->get_settings(); + + switch ( $this->feature_instance::ID ) { + case ContentResizing::ID: + case TitleGeneration::ID: + $new_settings[ static::ID ]['number_of_suggestions'] = sanitize_number_of_responses_field( 'number_of_suggestions', $new_settings[ static::ID ], $settings[ static::ID ] ); + break; + } + + return $new_settings; + } + + /** + * Output the excerpt script. + * + * This will make a request to the Chrome AI API to generate an excerpt. + */ + public function output_excerpt_script() { + // TODO: ensure this only loads on single admin content. + ?> + + generate_excerpt( $post_id, $args ); + break; + } + + return $return; + } + + /** + * Generate an excerpt. + * + * @param int $post_id The Post ID we're processing + * @param array $args Arguments passed in. + * @return string|WP_Error + */ + public function generate_excerpt( int $post_id = 0, array $args = [] ) { + if ( ! $post_id || ! get_post( $post_id ) ) { + return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required to generate an excerpt.', 'classifai' ) ); + } + + $feature = new ExcerptGeneration(); + $settings = $feature->get_settings(); + $args = wp_parse_args( + array_filter( $args ), + [ + 'content' => '', + 'title' => get_the_title( $post_id ), + ] + ); + + // These checks (and the one above) happen in the REST permission_callback, + // but we run them again here in case this method is called directly. + if ( empty( $settings ) || ! $feature->is_feature_enabled() ) { + return new WP_Error( 'not_enabled', esc_html__( 'Excerpt generation is disabled or authentication failed. Please check your settings.', 'classifai' ) ); + } + + $excerpt_length = absint( $settings['length'] ?? 55 ); + $excerpt_prompt = esc_textarea( get_default_prompt( $settings['generate_excerpt_prompt'] ) ?? $feature->prompt ); + + // Replace our variables in the prompt. + $prompt_search = array( '{{WORDS}}', '{{TITLE}}' ); + $prompt_replace = array( $excerpt_length, $args['title'] ); + $prompt = str_replace( $prompt_search, $prompt_replace, $excerpt_prompt ); + + /** + * Filter the prompt we will send to Chrome AI. + * + * @since x.x.x + * @hook classifai_chrome_ai_excerpt_prompt + * + * @param {string} $prompt Prompt we are sending. Gets added before post content. + * @param {int} $post_id ID of post we are summarizing. + * @param {int} $excerpt_length Length of final excerpt. + * + * @return {string} Prompt. + */ + $prompt = apply_filters( 'classifai_chrome_ai_excerpt_prompt', $prompt, $post_id, $excerpt_length ); + + /** + * Filter the request body before sending to Chrome AI. + * + * @since x.x.x + * @hook classifai_chrome_ai_excerpt_request_body + * + * @param {array} $body Request body that will be sent. + * @param {int} $post_id ID of post we are summarizing. + * + * @return {array} Request body. + */ + $body = apply_filters( + 'classifai_chrome_ai_excerpt_request_body', + [ + 'prompt' => 'You will be provided with content delimited by triple quotes. ' . $prompt, + 'content' => $this->get_content( $post_id, $excerpt_length, false, $args['content'] ), + 'func' => 'classifaiChromeAITextGeneration', + ], + $post_id + ); + + return $body; + } + + /** + * Get our content. + * + * We don't trim content here as we don't know for sure which model + * someone is using. + * + * @param int $post_id Post ID to get content from. + * @param int $return_length Word length of returned content. + * @param bool $use_title Whether to use the title or not. + * @param string $post_content The post content. + * @return string + */ + public function get_content( int $post_id = 0, int $return_length = 0, bool $use_title = true, string $post_content = '' ): string { + $normalizer = new Normalizer(); + + if ( empty( $post_content ) ) { + $post = get_post( $post_id ); + $post_content = apply_filters( 'the_content', $post->post_content ); + } + + $post_content = preg_replace( '#\[.+\](.+)\[/.+\]#', '$1', $post_content ); + + // Add the title to the content, if needed, and normalize things. + if ( $use_title ) { + $content = $normalizer->normalize( $post_id, $post_content ); + } else { + $content = $normalizer->normalize_content( $post_content, '', $post_id ); + } + + /** + * Filter content that will get sent to Chrome AI. + * + * @since x.x.x + * @hook classifai_chrome_ai_content + * + * @param {string} $content Content that will be sent. + * @param {int} $post_id ID of post we are summarizing. + * + * @return {string} Content. + */ + return apply_filters( 'classifai_chrome_ai_content', $content, $post_id ); + } +} diff --git a/includes/Classifai/Services/LanguageProcessing.php b/includes/Classifai/Services/LanguageProcessing.php index 436aae0e9..e5ee0ad19 100644 --- a/includes/Classifai/Services/LanguageProcessing.php +++ b/includes/Classifai/Services/LanguageProcessing.php @@ -48,6 +48,7 @@ public static function get_service_providers(): array { 'Classifai\Providers\Azure\OpenAI', 'Classifai\Providers\AWS\AmazonPolly', 'Classifai\Providers\Azure\Embeddings', + 'Classifai\Providers\Browser\ChromeAI', ] ); } diff --git a/src/js/features/excerpt-generation/panel.js b/src/js/features/excerpt-generation/panel.js index 292caabcb..aa40f0f48 100644 --- a/src/js/features/excerpt-generation/panel.js +++ b/src/js/features/excerpt-generation/panel.js @@ -47,8 +47,23 @@ function PostExcerpt( { excerpt, onUpdateExcerpt } ) { method: 'POST', data: { id: postId, content: postContent, title: postTitle }, } ).then( - ( res ) => { - onUpdateExcerpt( res ); + async ( res ) => { + // Support calling a function from the response for browser AI. + if ( typeof res === 'object' ) { + if ( res.hasOwnProperty( 'func' ) ) { + res = + 'undefined' !== typeof window[ res.func ] + ? await window[ res.func ]( + res?.prompt, + res?.content + ) + : ''; + } else { + res = ''; + } + } + + onUpdateExcerpt( res.trim() ); setError( false ); setIsLoading( false ); }, From 3420cdab92f7e9b470d65d1cc2e69780fdd152cc Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 15 Oct 2024 16:48:51 -0600 Subject: [PATCH 2/6] Add support for generating titles using Chrome AI --- .../Classifai/Features/TitleGeneration.php | 2 + .../Classifai/Providers/Browser/ChromeAI.php | 74 ++++++++++++++++++- src/js/features/title-generation/index.js | 18 ++++- 3 files changed, 90 insertions(+), 4 deletions(-) diff --git a/includes/Classifai/Features/TitleGeneration.php b/includes/Classifai/Features/TitleGeneration.php index a86d74388..ede1aa70f 100644 --- a/includes/Classifai/Features/TitleGeneration.php +++ b/includes/Classifai/Features/TitleGeneration.php @@ -5,6 +5,7 @@ use Classifai\Providers\Azure\OpenAI; use Classifai\Providers\GoogleAI\GeminiAPI; use Classifai\Providers\OpenAI\ChatGPT; +use Classifai\Providers\Browser\ChromeAI; use Classifai\Services\LanguageProcessing; use WP_REST_Server; use WP_REST_Request; @@ -45,6 +46,7 @@ public function __construct() { ChatGPT::ID => __( 'OpenAI ChatGPT', 'classifai' ), GeminiAPI::ID => __( 'Google AI (Gemini API)', 'classifai' ), OpenAI::ID => __( 'Azure OpenAI', 'classifai' ), + ChromeAI::ID => __( 'Chrome AI', 'classifai' ), ]; } diff --git a/includes/Classifai/Providers/Browser/ChromeAI.php b/includes/Classifai/Providers/Browser/ChromeAI.php index 489cacce4..cf581748d 100644 --- a/includes/Classifai/Providers/Browser/ChromeAI.php +++ b/includes/Classifai/Providers/Browser/ChromeAI.php @@ -49,7 +49,6 @@ public function render_provider_fields() { switch ( $this->feature_instance::ID ) { case ContentResizing::ID: - case TitleGeneration::ID: add_settings_field( static::ID . '_number_of_suggestions', esc_html__( 'Number of suggestions', 'classifai' ), @@ -88,7 +87,6 @@ public function get_default_provider_settings(): array { */ switch ( $this->feature_instance::ID ) { case ContentResizing::ID: - case TitleGeneration::ID: $common_settings['number_of_suggestions'] = 1; break; } @@ -107,7 +105,6 @@ public function sanitize_settings( array $new_settings ): array { switch ( $this->feature_instance::ID ) { case ContentResizing::ID: - case TitleGeneration::ID: $new_settings[ static::ID ]['number_of_suggestions'] = sanitize_number_of_responses_field( 'number_of_suggestions', $new_settings[ static::ID ], $settings[ static::ID ] ); break; } @@ -122,6 +119,7 @@ public function sanitize_settings( array $new_settings ): array { */ public function output_excerpt_script() { // TODO: ensure this only loads on single admin content. + // TODO: support classic editor. ?> - 'You will be provided with content delimited by triple quotes. ' . $prompt, 'content' => $this->get_content( $post_id, $excerpt_length, false, $args['content'] ), - 'func' => 'classifaiChromeAITextGeneration', + 'func' => static::ID, ], $post_id ); @@ -276,7 +227,7 @@ public function generate_title( int $post_id = 0, array $args = [] ) { [ 'prompt' => 'You will be provided with content delimited by triple quotes. ' . $prompt, 'content' => $this->get_content( $post_id, 0, false, $args['content'] ), - 'func' => 'classifaiChromeAITextGeneration', + 'func' => static::ID, ], $post_id ); @@ -335,7 +286,7 @@ public function resize_content( int $post_id, array $args = array() ) { [ 'prompt' => 'You will be provided with content delimited by triple quotes. ' . $prompt, 'content' => esc_html( $args['content'] ), - 'func' => 'classifaiChromeAITextGeneration', + 'func' => static::ID, ], $post_id ); diff --git a/src/js/features/content-resizing/index.js b/src/js/features/content-resizing/index.js index 6dde93dd4..50bf1f33a 100644 --- a/src/js/features/content-resizing/index.js +++ b/src/js/features/content-resizing/index.js @@ -1,5 +1,8 @@ /* eslint-disable @wordpress/no-unsafe-wp-apis */ /* eslint-disable no-shadow */ +/** + * External Dependencies. + */ import { registerPlugin } from '@wordpress/plugins'; import { store as blockEditorStore, @@ -23,7 +26,11 @@ import { } from '@wordpress/wordcount'; import { __, _nx } from '@wordpress/i18n'; +/** + * Internal Dependencies. + */ import { DisableFeatureButton } from '../../components'; +import { browserAITextGeneration } from '../../helpers'; import './index.scss'; const aiIconSvg = ( @@ -256,13 +263,11 @@ const ContentResizingPlugin = () => { typeof __textArray === 'object' && __textArray.hasOwnProperty( 'func' ) ) { - const res = - 'undefined' !== typeof window[ __textArray.func ] - ? await window[ __textArray.func ]( - __textArray?.prompt, - __textArray?.content - ) - : ''; + const res = await browserAITextGeneration( + __textArray.func, + __textArray?.prompt, + __textArray?.content + ); __textArray = [ res.trim() ]; } } else { diff --git a/src/js/features/excerpt-generation/panel.js b/src/js/features/excerpt-generation/panel.js index aa40f0f48..e2de99b6e 100644 --- a/src/js/features/excerpt-generation/panel.js +++ b/src/js/features/excerpt-generation/panel.js @@ -12,6 +12,7 @@ import apiFetch from '@wordpress/api-fetch'; * Internal Dependencies. */ import { DisableFeatureButton } from '../../components'; +import { browserAITextGeneration } from '../../helpers'; /** * PostExcerpt component. @@ -51,13 +52,11 @@ function PostExcerpt( { excerpt, onUpdateExcerpt } ) { // Support calling a function from the response for browser AI. if ( typeof res === 'object' ) { if ( res.hasOwnProperty( 'func' ) ) { - res = - 'undefined' !== typeof window[ res.func ] - ? await window[ res.func ]( - res?.prompt, - res?.content - ) - : ''; + res = await browserAITextGeneration( + res.func, + res?.prompt, + res?.content + ); } else { res = ''; } diff --git a/src/js/features/title-generation/index.js b/src/js/features/title-generation/index.js index a661f4f62..331627903 100644 --- a/src/js/features/title-generation/index.js +++ b/src/js/features/title-generation/index.js @@ -20,6 +20,7 @@ import apiFetch from '@wordpress/api-fetch'; * Internal Dependencies. */ import { DisableFeatureButton } from '../../components'; +import { browserAITextGeneration } from '../../helpers'; const { classifaiChatGPTData } = window; @@ -69,13 +70,11 @@ const TitleGenerationPlugin = () => { // Support calling a function from the response for browser AI. if ( typeof res === 'object' ) { if ( res.hasOwnProperty( 'func' ) ) { - res = - 'undefined' !== typeof window[ res.func ] - ? await window[ res.func ]( - res?.prompt, - res?.content - ) - : ''; + res = await browserAITextGeneration( + res.func, + res?.prompt, + res?.content + ); res = [ res.trim() ]; } else { res = []; diff --git a/src/js/helpers.js b/src/js/helpers.js index b7407f117..e1e756b4f 100644 --- a/src/js/helpers.js +++ b/src/js/helpers.js @@ -63,3 +63,56 @@ export const handleClick = ( { } ); }; + +/** + * Make a request to a browser AI to generate text. + * + * @param {string} provider Provider to use. + * @param {string} prompt Prompt to send to the API. + * @param {string} content Content to add in addition to the prompt. + */ +export const browserAITextGeneration = async ( + provider = '', + prompt = '', + content = '' +) => { + switch ( provider ) { + case 'chrome_ai': + return chromeAITextGeneration( prompt, content ); + default: + return ''; + } +}; + +/** + * Make a request to the Chrome AI API to generate text. + * + * @param {string} prompt Prompt to send to the API. + * @param {string} content Content to add in addition to the prompt. + */ +export const chromeAITextGeneration = async ( prompt = '', content = '' ) => { + let result = ''; + + if ( ! window.ai ) { + return result; + } + + const supportsTextGeneration = await window.ai.languageModel.capabilities(); + + if ( + supportsTextGeneration && + supportsTextGeneration.available === 'readily' + ) { + const session = await window.ai.languageModel.create( { + initialPrompts: [ + { + role: 'system', + content: prompt, + }, + ], + } ); + result = await session.prompt( `"""${ content }"""` ); + } + + return result; +}; From 929a15d598f65b87bd1ef07760fcb8bfd21422fa Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 15 Oct 2024 20:40:03 -0600 Subject: [PATCH 5/6] Ensure the AI method we need is available so an error isn't thrown --- src/js/helpers.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/js/helpers.js b/src/js/helpers.js index e1e756b4f..3015db8b2 100644 --- a/src/js/helpers.js +++ b/src/js/helpers.js @@ -97,7 +97,8 @@ export const chromeAITextGeneration = async ( prompt = '', content = '' ) => { return result; } - const supportsTextGeneration = await window.ai.languageModel.capabilities(); + const supportsTextGeneration = + await window.ai.languageModel?.capabilities(); if ( supportsTextGeneration && From 34573700caf8c7c900475734e3142c7da26b857c Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 15 Oct 2024 21:32:11 -0600 Subject: [PATCH 6/6] Fix title generation --- src/js/features/title-generation/index.js | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/src/js/features/title-generation/index.js b/src/js/features/title-generation/index.js index 331627903..4362afb75 100644 --- a/src/js/features/title-generation/index.js +++ b/src/js/features/title-generation/index.js @@ -68,17 +68,13 @@ const TitleGenerationPlugin = () => { } ).then( async ( res ) => { // Support calling a function from the response for browser AI. - if ( typeof res === 'object' ) { - if ( res.hasOwnProperty( 'func' ) ) { - res = await browserAITextGeneration( - res.func, - res?.prompt, - res?.content - ); - res = [ res.trim() ]; - } else { - res = []; - } + if ( typeof res === 'object' && res.hasOwnProperty( 'func' ) ) { + res = await browserAITextGeneration( + res.func, + res?.prompt, + res?.content + ); + res = [ res.trim() ]; } setData( res );