diff --git a/.eslintrc.json b/.eslintrc.json index 4ca16399d..f88862bb7 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,7 +23,9 @@ "React": "readonly", "Block": "readonly", "classifai_term_cleanup_params": "readonly", - "classifAISettings": "readonly" + "classifAISettings": "readonly", + "DOMParser": "readonly", + "MutationObserver": "readonly" }, "rules": { "react/jsx-no-undef": "off" diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php new file mode 100644 index 000000000..b99add5b1 --- /dev/null +++ b/includes/Classifai/Features/RewriteTone.php @@ -0,0 +1,250 @@ +label = __( 'Rewrite Tone', 'classifai' ); + + // Contains all providers that are registered to the service. + $this->provider_instances = $this->get_provider_instances( LanguageProcessing::get_service_providers() ); + + // Contains just the providers this feature supports. + $this->supported_providers = [ + ChatGPT::ID => __( 'OpenAI ChatGPT', 'classifai' ), + ]; + } + + /** + * Set up necessary hooks. + * + * We utilize this so we can register the REST route. + */ + public function setup() { + parent::setup(); + add_action( 'rest_api_init', [ $this, 'register_endpoints' ] ); + } + + /** + * Set up necessary hooks. + */ + public function feature_setup() { + add_action( 'enqueue_block_assets', [ $this, 'enqueue_editor_assets' ] ); + } + + /** + * Register any needed endpoints. + */ + public function register_endpoints() { + register_rest_route( + 'classifai/v1', + 'rewrite-tone', + [ + 'methods' => WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'rest_endpoint_callback' ], + 'permission_callback' => '__return_true', + 'args' => [ + 'id' => [ + 'required' => true, + 'type' => 'integer', + 'sanitize_callback' => 'absint', + 'description' => esc_html__( 'Post ID to resize the content for.', 'classifai' ), + ], + 'content' => [ + 'type' => 'array', + 'sanitize_callback' => function ( $content_array ) { + if ( is_array( $content_array ) ) { + return array_map( + function ( $item ) { + $item['clientId'] = sanitize_text_field( $item['clientId'] ); + $item['content'] = wp_kses_post( $item['content'] ); + return $item; + }, + $content_array + ); + } + + return []; + }, + 'validate_callback' => function ( $content_array ) { + if ( is_array( $content_array ) ) { + foreach ( $content_array as $item ) { + if ( ! isset( $item['clientId'] ) || ! is_string( $item['clientId'] ) ) { + return new WP_Error( 'rewrite_tone_invalid_client_id', __( 'Each item must have a valid clientId string.', 'classifai' ), [ 'status' => 400 ] ); + } + + if ( ! isset( $item['content'] ) || ! is_string( $item['content'] ) ) { + return new WP_Error( 'rewrite_tone_invalid_content', __( 'Each item must have valid content as a string.', 'classifai' ), [ 'status' => 400 ] ); + } + } + return true; + } + return new WP_Error( 'rewrite_tone_invalid_data_format', __( 'Content must be an array of objects.', 'classifai' ), [ 'status' => 400 ] ); + }, + 'description' => esc_html__( 'The content to resize.', 'classifai' ), + ], + ], + ] + ); + } + + /** + * Check if a given request has access to resize content. + * + * @param WP_REST_Request $request Full data about the request. + * @return WP_Error|bool + */ + public function resize_content_permissions_check( WP_REST_Request $request ) { + $post_id = $request->get_param( 'id' ); + + // Ensure we have a logged in user that can edit the item. + if ( empty( $post_id ) || ! current_user_can( 'edit_post', $post_id ) ) { + return false; + } + + $post_type = get_post_type( $post_id ); + $post_type_obj = get_post_type_object( $post_type ); + + // Ensure the post type is allowed in REST endpoints. + if ( ! $post_type || empty( $post_type_obj ) || empty( $post_type_obj->show_in_rest ) ) { + return false; + } + + // Ensure the feature is enabled. Also runs a user check. + if ( ! $this->is_feature_enabled() ) { + return new WP_Error( 'not_enabled', esc_html__( 'Rewrite Tone is not currently enabled.', 'classifai' ) ); + } + + return true; + } + + /** + * Generic request handler for all our custom routes. + * + * @param WP_REST_Request $request The full request object. + * @return \WP_REST_Response + */ + public function rest_endpoint_callback( WP_REST_Request $request ) { + $route = $request->get_route(); + + if ( strpos( $route, '/classifai/v1/rewrite-tone' ) === 0 ) { + return rest_ensure_response( + $this->run( + $request->get_param( 'id' ), + 'rewrite_tone', + [ + 'content' => $request->get_param( 'content' ), + 'emotion' => $request->get_param( 'emotion' ), + 'formality' => $request->get_param( 'formality' ), + 'intent' => $request->get_param( 'intent' ), + 'audience' => $request->get_param( 'audience' ), + ] + ) + ); + } + + return parent::rest_endpoint_callback( $request ); + } + + /** + * Enqueue the editor scripts. + */ + public function enqueue_editor_assets() { + global $post; + + if ( empty( $post ) || ! is_admin() ) { + return; + } + + wp_enqueue_script( + 'classifai-plugin-rewrite-tone-js', + CLASSIFAI_PLUGIN_URL . 'dist/classifai-plugin-rewrite-tone.js', + array_merge( + get_asset_info( 'classifai-plugin-rewrite-tone', 'dependencies' ), + array( Feature::PLUGIN_AREA_SCRIPT ) + ), + get_asset_info( 'classifai-plugin-rewrite-tone', 'version' ), + true + ); + } + + /** + * Get the description for the enable field. + * + * @return string + */ + public function get_enable_description(): string { + return esc_html__( '"Condense this text" and "Expand this text" menu items will be added to the paragraph block\'s toolbar menu.', 'classifai' ); + } + + /** + * Add any needed custom fields. + */ + public function add_custom_settings_fields() { + $settings = $this->get_settings(); + + add_settings_field( + 'rewrite_tone_prompt', + esc_html__( 'Prompt', 'classifai' ), + [ $this, 'render_prompt_repeater_field' ], + $this->get_option_name(), + $this->get_option_name() . '_section', + [ + 'label_for' => 'rewrite_tone_prompt', + 'default_value' => $settings['rewrite_tone_prompt'], + 'description' => esc_html__( 'Add a custom prompt, if desired.', 'classifai' ), + ] + ); + } + + /** + * Returns the default settings for the feature. + * + * @return array + */ + public function get_feature_default_settings(): array { + return [ + 'rewrite_tone_prompt' => [ + [ + 'title' => esc_html__( 'ClassifAI default', 'classifai' ), + 'original' => 1, + ], + ], + 'provider' => ChatGPT::ID, + ]; + } + + /** + * Sanitizes the default feature settings. + * + * @param array $new_settings Settings being saved. + * @return array + */ + public function sanitize_default_feature_settings( array $new_settings ): array { + $new_settings['rewrite_tone_prompt'] = sanitize_prompts( 'rewrite_tone_prompt', $new_settings ); + + return $new_settings; + } +} diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php index 786a0b222..bf73923b2 100644 --- a/includes/Classifai/Providers/OpenAI/ChatGPT.php +++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php @@ -14,6 +14,7 @@ use Classifai\Features\KeyTakeaways; use Classifai\Providers\Provider; use Classifai\Normalizer; +use Classifai\Features\RewriteTone; use WP_Error; use function Classifai\get_default_prompt; @@ -200,7 +201,7 @@ public function sanitize_api_key( array $new_settings ): string { */ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '', 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' ) ); + return new WP_Error( 'post_id_required', esc_html__( 'A valid post ID is required.', 'classifai' ) ); } $route_to_call = strtolower( $route_to_call ); @@ -224,6 +225,9 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '' case 'resize_content': $return = $this->resize_content( $post_id, $args ); break; + case 'rewrite_tone': + $return = $this->rewrite_tone( $post_id, $args ); + break; case 'key_takeaways': $return = $this->generate_key_takeaways( $post_id, $args ); break; @@ -870,6 +874,75 @@ public function resize_content( int $post_id, array $args = array() ) { return $return; } + /** + * Rewrite the tone of the content. + * + * @param int $post_id The Post Id we're processing + * @param array $args Arguments passed in. + */ + public function rewrite_tone( int $post_id, array $args = [] ) { + $feature = new RewriteTone(); + $settings = $feature->get_settings(); + $request = new APIRequest( $settings[ static::ID ]['api_key'] ?? '', $feature->get_option_name() ); + + $emotion = sanitize_text_field( $args['emotion'] ?? 'happy' ); + $formality = sanitize_text_field( $args['formality'] ?? 'formal' ); + $intent = sanitize_text_field( $args['intent'] ?? 'storytelling' ); + $audience = sanitize_text_field( $args['audience'] ?? 'general' ); + $prompt = "Rewrite the following content with the following tone attributes: Emotion: {$emotion}, Formality: {$formality}, Intent: {$intent}, Audience: {$audience}. Ensure that the rewritten content aligns with the selected attributes. Keep it {$formality} and make it {$intent}. The tone should reflect a {$emotion} sentiment while ensuring it resonates well with a {$audience} audience."; + + $body = apply_filters( + 'classifai_chatgpt_resize_content_request_body', + [ + 'model' => $this->chatgpt_model, + 'messages' => [ + [ + 'role' => 'system', + 'content' => $prompt, + ], + [ + 'role' => 'system', + 'content' => 'Rewrite the above content while maintaining its original meaning but transforming it according to the specified tone attributes.', + ], + [ + 'role' => 'system', + 'content' => "Please return each modified content with its corresponding 'clientId'. This is extremely important.", + ], + [ + 'role' => 'system', + 'content' => 'The inline styles and HTML attributes should be preserved in the response.', + ], + [ + 'role' => 'system', + 'content' => 'The HTML in the input should be preserved in the response.', + ], + [ + 'role' => 'user', + 'content' => wp_json_encode( $args['content'] ), + ], + ], + ], + ); + + $response = $request->post( + $this->chatgpt_url, + [ + 'body' => wp_json_encode( $body ), + ] + ); + + $return = []; + + foreach ( $response['choices'] as $choice ) { + if ( isset( $choice['message'], $choice['message']['content'] ) ) { + // ChatGPT often adds quotes to strings, so remove those as well as extra spaces. + $return[] = trim( $choice['message']['content'], ' "\'' ); + } + } + + return $return; + } + /** * Generate key takeaways from content. * diff --git a/includes/Classifai/Services/ServicesManager.php b/includes/Classifai/Services/ServicesManager.php index 93b577d46..c52f45c7b 100644 --- a/includes/Classifai/Services/ServicesManager.php +++ b/includes/Classifai/Services/ServicesManager.php @@ -77,6 +77,7 @@ public function register_language_processing_features( array $features ): array '\Classifai\Features\TitleGeneration', '\Classifai\Features\ExcerptGeneration', '\Classifai\Features\ContentResizing', + '\Classifai\Features\RewriteTone', '\Classifai\Features\KeyTakeaways', '\Classifai\Features\TextToSpeech', '\Classifai\Features\AudioTranscriptsGeneration', diff --git a/src/js/components/Inject-iframe-styles.js b/src/js/components/Inject-iframe-styles.js new file mode 100644 index 000000000..597074eca --- /dev/null +++ b/src/js/components/Inject-iframe-styles.js @@ -0,0 +1,86 @@ +import { useEffect, useRef, createPortal, render } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +import { useEditorCanvas } from '../hooks'; + +export const InjectIframeStyles = ( { children } ) => { + const iframeCanvas = useEditorCanvas(); + + // Reference to the iframe in which we show blocks for preview. + const iframeRef = useRef( null ); + + useEffect( () => { + if ( ! iframeCanvas || ! iframeRef.current ) { + return; + } + + // Get the newly created iframe's document. + const iframeDocument = + iframeRef.current.contentDocument || + iframeRef.current.contentWindow.document; + + // Copy the styles from the existing iframe (editor canvas). + const editorIframeDocument = + iframeCanvas.contentDocument || iframeCanvas.contentWindow.document; + const iframeStyles = editorIframeDocument.querySelectorAll( + 'link[rel="stylesheet"], style' + ); + + // Append styles (external & internal) to the new iframe's body. + iframeStyles.forEach( ( style ) => { + if ( style.tagName === 'LINK' ) { + iframeDocument.head.appendChild( style.cloneNode( true ) ); + } else if ( style.tagName === 'STYLE' ) { + const clonedStyle = document.createElement( 'style' ); + clonedStyle.textContent = style.textContent; + iframeDocument.head.appendChild( clonedStyle ); + } + } ); + + const intervalId = setInterval( () => { + if ( ! iframeDocument.body ) { + return; + } + + iframeDocument.body.classList.add( + 'block-editor-iframe__body', + 'editor-styles-wrapper', + 'post-type-post', + 'admin-color-fresh', + 'wp-embed-responsive' + ); + iframeDocument.body + .querySelector( '.is-root-container' ) + .classList.add( + 'is-desktop-preview', + 'is-layout-constrained', + 'wp-block-post-content-is-layout-constrained', + 'has-global-padding', + 'alignfull', + 'wp-block-post-content', + 'block-editor-block-list__layout' + ); + + clearInterval( intervalId ); + }, 100 ); + + // Use React Portal to render the children into the iframe container. + // TODO: Might need to replace with `createPortal` due to React 18. + const portal = createPortal( children, iframeDocument.body ); + render( portal, iframeDocument.body ); + }, [ iframeCanvas ] ); + + if ( ! iframeCanvas ) { + return null; + } + + return ( +
+ +
+ ); +}; diff --git a/src/js/components/index.js b/src/js/components/index.js index 2dff88b8f..93814baef 100644 --- a/src/js/components/index.js +++ b/src/js/components/index.js @@ -1,2 +1,3 @@ export * from './disable-feature-button'; export * from './user-selector'; +export * from './inject-iframe-styles'; diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js new file mode 100644 index 000000000..1d0492353 --- /dev/null +++ b/src/js/features/rewrite-tone/index.js @@ -0,0 +1,380 @@ +/* eslint-disable @wordpress/no-unsafe-wp-apis */ +import { registerPlugin } from '@wordpress/plugins'; +import { useRef, useState, useEffect } from '@wordpress/element'; +import { useDispatch } from '@wordpress/data'; +import { + store as blockEditorStore, + BlockEditorProvider, + BlockList, +} from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; +import { createBlock } from '@wordpress/blocks'; +import { + Modal, + Button, + Panel, + PanelBody, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, + SelectControl, +} from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; + +import { useSelectedBlocks } from '../../hooks'; +import { InjectIframeStyles } from '../../components'; +import { tones } from './tones'; +import { + filterAndFlattenAllowedBlocks, + getClientIdToBlockContentMapping, + stripOutermostTag, + replaceBlocksInSource, +} from '../../utils'; + +const apiUrl = `${ wpApiSettings.root }classifai/v1/rewrite-tone`; +const allowedTextBlocks = [ + 'core/paragraph', + 'core/heading', + 'core/list', + 'core/list-item', +]; + +function defaultToneAttribute( toneAttribute = '', value = null ) { + if ( ! toneAttribute ) { + return ''; + } + + if ( ! value ) { + return ( + window.localStorage.getItem( + `classifai-tone-attribute-${ toneAttribute }` + ) || '' + ); + } + + window.localStorage.setItem( + `classifai-tone-attribute-${ toneAttribute }`, + value + ); +} + +const RewriteTonePlugin = () => { + // Holds a reference to the original, unmodified editor blocks. + const blocksBackup = useRef( null ); + + // Flag indicating if the previewer modal is open. + const [ isPreviewVisible, setIsPreviewVisible ] = useState( false ); + + // Flag indicating if a rewrite is in progress. + const [ isRewriteInProgress, setIsRewriteInProgress ] = useState( false ); + + // Stores ChatGPT response. + const [ response, setResponse ] = useState( null ); + + // Stores all the editor blocks (modified and unmodified) that are created for preview. + const [ blocksForPreview, setBlocksForPreview ] = useState( [] ); + + // Stores the subset of editor blocks that have undergone tone rewriting. + const [ modifiedBlocks, setModifiedBlocks ] = useState( [] ); + + // We use this to replace blocks if the user is happy with the result. + const { replaceBlock } = useDispatch( blockEditorStore ); + + // Selected blocks in the block editor. + const allSelectedBlocks = useSelectedBlocks(); + + // Tone attributes from local storage. + const __defaultEmotion = defaultToneAttribute( 'emotion' ); + const __defaultFormality = defaultToneAttribute( 'formality' ); + const __defaultIntent = defaultToneAttribute( 'intent' ); + const __defaultAudience = defaultToneAttribute( 'audience' ); + + // Local states for tone attributes. + const [ emotion, setEmotion ] = useState( __defaultEmotion || 'happy' ); + const [ formality, setFormality ] = useState( + __defaultFormality || 'formal' + ); + const [ intent, setIntent ] = useState( __defaultIntent || 'storytelling' ); + const [ audience, setAudience ] = useState( + __defaultAudience || 'general' + ); + + /** + * Performs rewrite when triggered by the user on Button click. + * + * @return {void} + */ + async function rewriteTone() { + try { + // We backup the original blocks. + blocksBackup.current = wp.data + .select( blockEditorStore ) + .getBlocks(); + + setIsPreviewVisible( false ); + setIsRewriteInProgress( true ); + setBlocksForPreview( [] ); + + const filteredBlocks = getClientIdToBlockContentMapping( + filterAndFlattenAllowedBlocks( + allSelectedBlocks, + allowedTextBlocks + ) + ); + + let __response = await fetch( apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify( { + id: wp.data.select( editorStore ).getCurrentPostId(), + content: filteredBlocks, + emotion, + formality, + intent, + audience, + } ), + } ); + + setIsRewriteInProgress( false ); + + if ( ! __response.ok ) { + return; + } + + __response = await __response.json(); + setResponse( JSON.parse( __response ) ); + } catch ( e ) { + setIsRewriteInProgress( false ); + } + } + + /** + * Applies the result to the editor canvas when the user + * accepts it. + */ + const applyResult = () => { + modifiedBlocks.forEach( ( { clientId, blocks } ) => { + replaceBlock( clientId, blocks ); + } ); + + setIsPreviewVisible( false ); + }; + + useEffect( + function reactToResponse() { + if ( ! Array.isArray( response ) ) { + return; + } + + const __modifiedBlocks = response.map( + ( { clientId, content } ) => { + // We get the same block clientID in the response. + // Get the block using the clientID from the block editor data store. + const currentBlock = wp.data + .select( blockEditorStore ) + .getBlock( clientId ); + + // We apply the original block attributes to the current iterated block. + currentBlock.attributes = wp.data + .select( blockEditorStore ) + .getBlockAttributes( clientId ); + + // This will automatically create a new block by detecting the HTML. + let newBlock = wp.blocks.rawHandler( { HTML: content } ); + + if ( + Array.isArray( newBlock ) && + 1 === newBlock.length && + 'core/html' === newBlock[ 0 ].name + ) { + // If a List item block is selected (without selecting the List block), and + // sent in the request, the response also returns the HTML with
  • ...content...
  • . + // Gutenberg does not recognise
  • without