From 4e6dbb9e7a8bc2d9bf7d6cd6c900e398b0bd4762 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:14:06 +0530 Subject: [PATCH 01/31] register Tone rewriting --- includes/Classifai/Features/RewriteTone.php | 255 ++++++++++++++++++ .../Classifai/Providers/OpenAI/ChatGPT.php | 80 +++++- .../Classifai/Services/ServicesManager.php | 1 + 3 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 includes/Classifai/Features/RewriteTone.php diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php new file mode 100644 index 000000000..9cbc733a6 --- /dev/null +++ b/includes/Classifai/Features/RewriteTone.php @@ -0,0 +1,255 @@ +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' ), + ] + ) + ); + } + + 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', + 'placeholder' => $this->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' ), + 'prompt' => $this->prompt, + '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 7e126b11f..71724b551 100644 --- a/includes/Classifai/Providers/OpenAI/ChatGPT.php +++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php @@ -11,6 +11,7 @@ use Classifai\Features\TitleGeneration; use Classifai\Providers\Provider; use Classifai\Normalizer; +use Classifai\Features\RewriteTone; use WP_Error; use function Classifai\get_default_prompt; @@ -195,7 +196,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 ); @@ -215,6 +216,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; } return $return; @@ -663,6 +667,80 @@ 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() ); + $prompt = esc_textarea( get_default_prompt( $settings['rewrite_tone_prompt'] ) ?? $feature->prompt ); + + /** + * Filter the prompt we will send to ChatGPT. + * + * @since x.x.x + * @hook classifai_chatgpt_rewrite_tone_prompt + * + * @param {string} $prompt Prompt we are sending to ChatGPT. Gets added before post content. + * @param {int} $post_id ID of post we are summarizing. + * @param {array} $args Arguments passed to endpoint. + * + * @return {string} Prompt. + */ + $prompt = apply_filters( 'classifai_chatgpt_rewrite_tone_prompt', $prompt, $post_id, $args ); + + $body = apply_filters( + 'classifai_chatgpt_resize_content_request_body', + [ + 'model' => $this->chatgpt_model, + 'messages' => [ + [ + 'role' => 'system', + 'content' => $prompt, + ], + [ + 'role' => 'system', + 'content' => "Please return each modified content with its corresponding 'clientId'.", + ], + [ + '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; + } + /** * Get our content, trimming if needed. * diff --git a/includes/Classifai/Services/ServicesManager.php b/includes/Classifai/Services/ServicesManager.php index 9c76503ae..7dce55943 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\TextToSpeech', '\Classifai\Features\AudioTranscriptsGeneration', '\Classifai\Features\Moderation', From dbe1299d1a9b263019b566d9a8a50de83e861e82 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:14:26 +0530 Subject: [PATCH 02/31] setup webpack and eslintrc --- .eslintrc.json | 3 ++- webpack.config.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.eslintrc.json b/.eslintrc.json index 4ca16399d..e07d06f98 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -23,7 +23,8 @@ "React": "readonly", "Block": "readonly", "classifai_term_cleanup_params": "readonly", - "classifAISettings": "readonly" + "classifAISettings": "readonly", + "DOMParser": "readonly" }, "rules": { "react/jsx-no-undef": "off" diff --git a/webpack.config.js b/webpack.config.js index 98e9aa3b4..5de568f8b 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -26,6 +26,7 @@ module.exports = { 'classifai-plugin-text-to-speech': './src/js/features/text-to-speech/index.js', 'classifai-plugin-text-to-speech-frontend': './src/js/features/text-to-speech/frontend/index.js', 'classifai-plugin-content-resizing': './src/js/features/content-resizing/index.js', + 'classifai-plugin-rewrite-tone': './src/js/features/rewrite-tone/index.js', 'classifai-plugin-title-generation': './src/js/features/title-generation/index.js', 'classifai-plugin-classic-title-generation': './src/js/features/title-generation/classic/index.js', 'classifai-plugin-excerpt-generation': './src/js/features/excerpt-generation/index.js', From a901a54101ef1240819972161fd38cc7b22f9fde Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:14:58 +0530 Subject: [PATCH 03/31] add the useSelectedBlocks hook --- src/js/hooks/index.js | 1 + src/js/hooks/useSelectedBlocks.js | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/js/hooks/index.js create mode 100644 src/js/hooks/useSelectedBlocks.js diff --git a/src/js/hooks/index.js b/src/js/hooks/index.js new file mode 100644 index 000000000..4be75c9ac --- /dev/null +++ b/src/js/hooks/index.js @@ -0,0 +1 @@ +export { useSelectedBlocks } from './useSelectedBlocks'; \ No newline at end of file diff --git a/src/js/hooks/useSelectedBlocks.js b/src/js/hooks/useSelectedBlocks.js new file mode 100644 index 000000000..cc106d019 --- /dev/null +++ b/src/js/hooks/useSelectedBlocks.js @@ -0,0 +1,18 @@ +import { useSelect } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; + +/** + * Returns array of block objects of the blocks that are selected. + * + * @returns {Array} Array of block objects. + */ +export const useSelectedBlocks = () => { + return useSelect( ( select ) => { + const selectedBlock = select( blockEditorStore ).getSelectedBlock(); + const multiSelectedBlocks = + select( blockEditorStore ).getMultiSelectedBlocks(); + return selectedBlock + ? [ selectedBlock ] + : multiSelectedBlocks; + } ); +}; From 749a2c786e06681949453431f86caee704e185e4 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 18:21:10 +0530 Subject: [PATCH 04/31] register classifai-rewrite-tone-plugin --- src/js/features/rewrite-tone/index.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 src/js/features/rewrite-tone/index.js diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js new file mode 100644 index 000000000..d71cb5565 --- /dev/null +++ b/src/js/features/rewrite-tone/index.js @@ -0,0 +1,17 @@ +import { registerPlugin } from '@wordpress/plugins'; +import { useSelectedBlocks } from '../../hooks'; + +const { ClassifaiEditorSettingPanel } = window; + +const RewriteTonePlugin = () => { + const allSelectedBlocks = useSelectedBlocks(); + + return ( + + + ); +}; + +registerPlugin( 'classifai-rewrite-tone-plugin', { + render: RewriteTonePlugin, +} ); From f453c28de5285868eb08568c90c987a065df38af Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Tue, 14 Jan 2025 20:32:35 +0530 Subject: [PATCH 05/31] add the filterAndFlattenAllowedBlocks util --- src/js/features/rewrite-tone/index.js | 9 +++++++++ src/js/utils/index.js | 12 ++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 src/js/utils/index.js diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index d71cb5565..c26ccd982 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -1,10 +1,19 @@ import { registerPlugin } from '@wordpress/plugins'; + import { useSelectedBlocks } from '../../hooks'; +import { filterAndFlattenAllowedBlocks } from '../../utils'; const { ClassifaiEditorSettingPanel } = window; +const allowedTextBlocks = [ + 'core/paragraph', + 'core/heading', + 'core/list', + 'core/list-item', +]; const RewriteTonePlugin = () => { const allSelectedBlocks = useSelectedBlocks(); + const filteredBlocks = filterAndFlattenAllowedBlocks( allSelectedBlocks, allowedTextBlocks ); return ( diff --git a/src/js/utils/index.js b/src/js/utils/index.js new file mode 100644 index 000000000..7d4e6c370 --- /dev/null +++ b/src/js/utils/index.js @@ -0,0 +1,12 @@ +export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) => blocks.reduce( + ( acc, block ) => [ + ...acc, + ...( allowedBlocks.includes( block.name ) + ? [ block ] + : [] ), + ...( block.innerBlocks + ? filterAndFlattenAllowedBlocks( block.innerBlocks ) + : [] ), + ], + [] +); From c865d7ad49b3b0140ab6bdccea8508f19be9cb66 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sat, 18 Jan 2025 18:30:49 +0530 Subject: [PATCH 06/31] add the useEditorCanvas hook --- src/js/hooks/index.js | 3 ++- src/js/hooks/useEditorCanvas.js | 45 +++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/js/hooks/useEditorCanvas.js diff --git a/src/js/hooks/index.js b/src/js/hooks/index.js index 4be75c9ac..7d90a2596 100644 --- a/src/js/hooks/index.js +++ b/src/js/hooks/index.js @@ -1 +1,2 @@ -export { useSelectedBlocks } from './useSelectedBlocks'; \ No newline at end of file +export * from './useSelectedBlocks'; +export * from './useEditorCanvas'; diff --git a/src/js/hooks/useEditorCanvas.js b/src/js/hooks/useEditorCanvas.js new file mode 100644 index 000000000..ff6315d61 --- /dev/null +++ b/src/js/hooks/useEditorCanvas.js @@ -0,0 +1,45 @@ +import { useState, useEffect } from '@wordpress/element'; + +export const useEditorCanvas = () => { + const [ iframeCanvas, setIframeCanvas ] = useState( null ); + + useEffect( () => { + let observer; + + /** + * Function to check for the editor canvas iframe. + * + * @return {boolean} True if the iframe is found, false otherwise. + */ + const checkForCanvas = () => { + const __iframeCanvas = document.getElementsByName( 'editor-canvas' ); + if ( __iframeCanvas.length > 0 ) { + setIframeCanvas( __iframeCanvas[ 0 ] ); + return true; + } + return false; + }; + + // Perform an initial check in case the iframe already exists. + if ( ! checkForCanvas() ) { + // Set up the observer to listen for DOM mutations. + observer = new MutationObserver( () => { + if ( checkForCanvas() ) { + // Disconnect the observer once the iframe is found. + observer.disconnect(); + } + } ); + + // Observe changes to the DOM body or its descendants. + observer.observe( document.body, { + childList: true, + subtree: true, + } ); + } + + // Cleanup observer on component unmount. + return () => observer?.disconnect(); + }, [] ); + + return iframeCanvas; +}; From 7475d59ba69e1dcfb2cf50da2e36ae5bc615c315 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sat, 18 Jan 2025 18:31:39 +0530 Subject: [PATCH 07/31] add the InjectIframeStyles component --- src/js/components/InjectIframeStyles.js | 85 +++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/js/components/InjectIframeStyles.js diff --git a/src/js/components/InjectIframeStyles.js b/src/js/components/InjectIframeStyles.js new file mode 100644 index 000000000..9c7fe704d --- /dev/null +++ b/src/js/components/InjectIframeStyles.js @@ -0,0 +1,85 @@ +import { useEffect, createPortal } from '@wordpress/element'; + +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 ( +
+ +
+ ); +}; From b2eb2943caaf5b0836dbbc898d75fa24386e0d76 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sat, 18 Jan 2025 18:32:06 +0530 Subject: [PATCH 08/31] add the getClientIdToBlockContentMapping util --- src/js/utils/index.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/js/utils/index.js b/src/js/utils/index.js index 7d4e6c370..7499998fb 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -1,3 +1,5 @@ +import { getBlockContent } from '@wordpress/blocks'; + export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) => blocks.reduce( ( acc, block ) => [ ...acc, @@ -10,3 +12,14 @@ export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) ], [] ); + +/** + * Retrieves the mapping of client IDs to block content. + * + * @param {Array} blocks + * @returns {Object} An object where the keys are client IDs and the values are the corresponding block content. + */ +export const getClientIdToBlockContentMapping = ( blocks = [] ) => blocks.map( ( block ) => ( { + clientId: block.clientId, + content: getBlockContent( block ), +} ) ); From 217ee9a7ada5addd89cd6b259e8f73b35b3f35b8 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Wed, 26 Feb 2025 18:27:42 +0530 Subject: [PATCH 09/31] add the stripOutermostTag util --- src/js/utils/index.js | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/js/utils/index.js b/src/js/utils/index.js index 7499998fb..c3c551fdf 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -23,3 +23,21 @@ export const getClientIdToBlockContentMapping = ( blocks = [] ) => blocks.map( ( clientId: block.clientId, content: getBlockContent( block ), } ) ); + +/** + * Returns HTML string without the outermost tags. + * + * @param {string} htmlContent HTML as string. + * @return {string} HTML string without outermost tags stripped. + */ +export function stripOutermostTag( htmlContent = '' ) { + // Parse the input HTML string into a DOM structure + const parser = new DOMParser(); + const doc = parser.parseFromString( htmlContent, 'text/html' ); + + // Get the first element within the body (this is the outermost element) + const outermostElement = doc.body.firstElementChild; + + // Return the innerHTML of the outermost element, which removes the outermost tag + return outermostElement ? outermostElement.innerHTML : htmlContent; +} From de829aea03c71ad2519342ada870c316e1f45f8d Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Wed, 26 Feb 2025 18:28:18 +0530 Subject: [PATCH 10/31] implement the rewriteTone() method --- src/js/features/rewrite-tone/index.js | 72 +++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index c26ccd982..55bce29cc 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -1,9 +1,16 @@ import { registerPlugin } from '@wordpress/plugins'; +import { useRef, useState } from '@wordpress/element'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { store as editorStore } from '@wordpress/editor'; -import { useSelectedBlocks } from '../../hooks'; -import { filterAndFlattenAllowedBlocks } from '../../utils'; +import { useSelectedBlocks, useEditorCanvas } from '../../hooks'; +import { + filterAndFlattenAllowedBlocks, + getClientIdToBlockContentMapping, +} from '../../utils'; const { ClassifaiEditorSettingPanel } = window; +const apiUrl = `${ wpApiSettings.root }classifai/v1/rewrite-tone`; const allowedTextBlocks = [ 'core/paragraph', 'core/heading', @@ -12,11 +19,70 @@ const allowedTextBlocks = [ ]; const RewriteTonePlugin = () => { + // Holds a reference to the original, unmodified editor blocks. + const blocksBackup = useRef( null ); + + // Flag indicating if the previewer modal is open. + const [ isPopupVisible, setIsPopupVisible ] = useState( false ); + + // Flag indicating if a rewrite is in progress. + const [ isRewriteInProgress, setIsRewriteInProgress ] = useState( false ); + + // Stores all the editor blocks (modified and unmodified) that are created for preview. + const [ previewBlocks, setPreviewBlocks ] = useState( [] ); + + // Stores ChatGPT response. + const [ response, setResponse ] = useState( null ); + const allSelectedBlocks = useSelectedBlocks(); - const filteredBlocks = filterAndFlattenAllowedBlocks( allSelectedBlocks, allowedTextBlocks ); + + /** + * 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(); + + setIsPopupVisible( false ); + setIsRewriteInProgress( true ); + setPreviewBlocks( [] ); + + 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, + } ), + } ); + + setIsRewriteInProgress( false ); + + if ( ! __response.ok ) { + return; + } + + __response = await __response.json(); + setResponse( JSON.parse( __response ) ); + } catch ( e ) { + setIsRewriteInProgress( false ); + } + } return ( + ); }; From bb94e40086f71ae8eb348141545b903c5255ab82 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Fri, 28 Feb 2025 17:46:36 +0530 Subject: [PATCH 11/31] add replaceBlocksInSource util --- src/js/utils/index.js | 50 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/src/js/utils/index.js b/src/js/utils/index.js index c3c551fdf..ec115639c 100644 --- a/src/js/utils/index.js +++ b/src/js/utils/index.js @@ -13,6 +13,16 @@ export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) [] ); +/** + * Removes the delimiters from the content. + * + * @param {Array} blocks Array of { clientId, content } objects. + * @return {Array} Array of objects with content without delimiters. + */ +export const removeBlockDelimiters = ( content ) => { + return content.replace( //g, '' ); +}; + /** * Retrieves the mapping of client IDs to block content. * @@ -21,7 +31,7 @@ export const filterAndFlattenAllowedBlocks = ( blocks = [], allowedBlocks = [] ) */ export const getClientIdToBlockContentMapping = ( blocks = [] ) => blocks.map( ( block ) => ( { clientId: block.clientId, - content: getBlockContent( block ), + content: removeBlockDelimiters( getBlockContent( block ) ), } ) ); /** @@ -30,7 +40,7 @@ export const getClientIdToBlockContentMapping = ( blocks = [] ) => blocks.map( ( * @param {string} htmlContent HTML as string. * @return {string} HTML string without outermost tags stripped. */ -export function stripOutermostTag( htmlContent = '' ) { +export const stripOutermostTag = ( htmlContent = '' ) => { // Parse the input HTML string into a DOM structure const parser = new DOMParser(); const doc = parser.parseFromString( htmlContent, 'text/html' ); @@ -41,3 +51,39 @@ export function stripOutermostTag( htmlContent = '' ) { // Return the innerHTML of the outermost element, which removes the outermost tag return outermostElement ? outermostElement.innerHTML : htmlContent; } + + +/** + * Replaces subset of blocks in the copy of the editor's original blocks with + * modified blocks and returns a new array. + * + * Suppose the editor originally has 6 blocks and blocks 3 & 4 have undergone tone + * rewriting which returns blocks 3' and 4'. This function returns 1-2-3'-4'-5-6. + * + * @param {Array} sourceBlocks Array of original, unmodified editor blocks. + * @param {Array} modifiedBlocks Subset of editor blocks which have undergone tone rewriting. + * @return {Array} Array of blocks that include original and modified blocks. + */ +export const replaceBlocksInSource = ( sourceBlocks = [], modifiedBlocks = [] ) => { + const updateBlock = ( blocks ) => { + return blocks.map( ( block ) => { + const modified = modifiedBlocks.find( + ( modifiedBlock ) => + modifiedBlock.clientId === block.clientId + ); + + if ( modified ) { + return modified.blocks[ 0 ]; + } + + return { + ...block, + innerBlocks: block.innerBlocks + ? updateBlock( block.innerBlocks ) + : [], + }; + } ); + }; + + return updateBlock( sourceBlocks ); +} From 30b24b9c58d4b1dd2ee3de01423f3c22b8f30a0c Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Fri, 28 Feb 2025 17:47:12 +0530 Subject: [PATCH 12/31] add InjectIframeStyles component --- .../{InjectIframeStyles.js => Inject-iframe-styles.js} | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) rename src/js/components/{InjectIframeStyles.js => Inject-iframe-styles.js} (95%) diff --git a/src/js/components/InjectIframeStyles.js b/src/js/components/Inject-iframe-styles.js similarity index 95% rename from src/js/components/InjectIframeStyles.js rename to src/js/components/Inject-iframe-styles.js index 9c7fe704d..597074eca 100644 --- a/src/js/components/InjectIframeStyles.js +++ b/src/js/components/Inject-iframe-styles.js @@ -1,4 +1,5 @@ -import { useEffect, createPortal } from '@wordpress/element'; +import { useEffect, useRef, createPortal, render } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import { useEditorCanvas } from '../hooks'; From 15511012f20a60dbc8eb2a5bffe26b9b4312db8c Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Fri, 28 Feb 2025 17:48:06 +0530 Subject: [PATCH 13/31] :white_check_mark: implement rewrite tone with preview and replacement --- src/js/components/index.js | 1 + src/js/features/rewrite-tone/index.js | 133 ++++++++++++++++++++++++-- 2 files changed, 127 insertions(+), 7 deletions(-) diff --git a/src/js/components/index.js b/src/js/components/index.js index 2dff88b8f..bec790042 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 index 55bce29cc..2594f3b12 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -1,12 +1,19 @@ import { registerPlugin } from '@wordpress/plugins'; -import { useRef, useState } from '@wordpress/element'; -import { store as blockEditorStore } from '@wordpress/block-editor'; +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 } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; -import { useSelectedBlocks, useEditorCanvas } from '../../hooks'; +import { useSelectedBlocks } from '../../hooks'; +import { InjectIframeStyles } from '../../components'; import { filterAndFlattenAllowedBlocks, getClientIdToBlockContentMapping, + stripOutermostTag, + replaceBlocksInSource, } from '../../utils'; const { ClassifaiEditorSettingPanel } = window; @@ -28,12 +35,18 @@ const RewriteTonePlugin = () => { // Flag indicating if a rewrite is in progress. const [ isRewriteInProgress, setIsRewriteInProgress ] = useState( false ); - // Stores all the editor blocks (modified and unmodified) that are created for preview. - const [ previewBlocks, setPreviewBlocks ] = useState( [] ); - // 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 ); + const allSelectedBlocks = useSelectedBlocks(); /** @@ -50,7 +63,7 @@ const RewriteTonePlugin = () => { setIsPopupVisible( false ); setIsRewriteInProgress( true ); - setPreviewBlocks( [] ); + setBlocksForPreview( [] ); const filteredBlocks = getClientIdToBlockContentMapping( filterAndFlattenAllowedBlocks( allSelectedBlocks, allowedTextBlocks ) @@ -80,10 +93,116 @@ const RewriteTonePlugin = () => { } } + /** + * Applies the result to the editor canvas when the user + * accepts it. + */ + const applyResult = () => { + modifiedBlocks.forEach( ( { clientId, blocks } ) => { + replaceBlock( clientId, blocks ); + } ); + + setIsPopupVisible( 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
      , and hence rawHandler() returns a + // generic `core/html` block instead of a `core/list-item` block. + // + // We handle this separately by using `createBlock()` isntead. + newBlock = createBlock( currentBlock.name, { + // The response contains `
    • ` tags, which we remove here as they are added + // by `createBlock()`. If we don't do this, then nested List item blocks will be + // created. + content: stripOutermostTag( content ), + } ); + + return { + clientId, + blocks: [ newBlock ], + }; + } + + console.log( newBlock ) + + return { + clientId, + blocks: newBlock, + }; + } ); + + const __blocksForPreview = replaceBlocksInSource( + blocksBackup.current, + __modifiedBlocks + ); + + setBlocksForPreview( __blocksForPreview ); + setModifiedBlocks( __modifiedBlocks ); + setIsPopupVisible( true ); + }, [ response ] ); + return ( + <> + + { isPopupVisible && ( + setIsPopupVisible( false ) } + > + + + + +
      + + +
      +
      +
      + ) } + ); }; From 287675080df5000c1208f7126e347825f317d39d Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sun, 2 Mar 2025 23:53:15 +0530 Subject: [PATCH 14/31] add tones --- src/js/features/rewrite-tone/tones.js | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 src/js/features/rewrite-tone/tones.js diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js new file mode 100644 index 000000000..c96c177e2 --- /dev/null +++ b/src/js/features/rewrite-tone/tones.js @@ -0,0 +1,86 @@ +import { __ } from '@wordpress/i18n'; + +export const tones = { + emotion: { + label: __( 'Emotion', 'classifai' ), + values: [ + { + key: 'happy', + label: __( 'Happy', 'classifai' ), + }, + { + key: 'neutral', + label: __( 'Neutral', 'classifai' ), + }, + { + key: 'sad', + label: __( 'Sad', 'classifai' ), + }, + ], + }, + formality: { + label: __( 'Formality', 'classifai' ), + values: [ + { + key: 'formal', + label: __( 'Formal', 'classifai' ), + description: __( 'Professional, structured, business-like.', 'classifai' ), + }, + { + key: 'informal', + label: __( 'Informal', 'classifai' ), + description: __( 'Conversational, relaxed.', 'classifai' ), + }, + { + key: 'supportive', + label: __( 'Supportive', 'classifai' ), + description: __( 'Reassuring, helpful.', 'classifai' ), + }, + ], + }, + intent: { + label: __( 'Intent', 'classifai' ), + value: [ + { + key: 'dramatic', + label: __( 'Dramatic', 'classifai' ), + description: __( 'Intense, theatrical.', 'classifai' ), + }, + { + key: 'persuasive', + label: __( 'Persuasive', 'classifai' ), + description: __( 'Convincing, compelling.', 'classifai' ), + }, + { + key: 'storytelling', + label: __( 'Storytelling', 'classifai' ), + description: __( 'Engaging, immersive.', 'classifai' ), + }, + ], + }, + audience: { + label: __( 'Audience', 'classifai' ), + value: [ + { + key: 'educational', + label: __( 'Educational', 'classifai' ), + description: __( 'Clear, instructive.', 'classifai' ), + }, + { + key: 'general', + label: __( 'General Audience', 'classifai' ), + description: __( 'Balanced, universally understandable.', 'classifai' ), + }, + { + key: 'maketing', + label: __( 'Marketing & Sales', 'classifai' ), + description: __( 'Promotional, action-driven.', 'classifai' ), + }, + { + key: 'professional', + label: __( 'Business & Professional', 'classifai' ), + description: __( 'Corporate, industry-focused.', 'classifai' ), + }, + ], + } +}; \ No newline at end of file From 2812643d7e228efda6f1126ac0ceed720f006255 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Sun, 2 Mar 2025 23:54:11 +0530 Subject: [PATCH 15/31] move ClassifAI slot-fills to their separate files --- .../slot-fill/editor-header-plugin-area.js | 76 +++++++++++++++++++ .../slot-fill/editor-sidebar-plugin-area.js | 41 ++++++++++ src/js/features/slot-fill/index.js | 43 +---------- 3 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 src/js/features/slot-fill/editor-header-plugin-area.js create mode 100644 src/js/features/slot-fill/editor-sidebar-plugin-area.js diff --git a/src/js/features/slot-fill/editor-header-plugin-area.js b/src/js/features/slot-fill/editor-header-plugin-area.js new file mode 100644 index 000000000..acb27dbd9 --- /dev/null +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -0,0 +1,76 @@ +/** + * External dependencies + */ +import { + SlotFillProvider, + Slot, + Fill, + Button, + Popover, + +} from '@wordpress/components'; +import { PluginArea } from '@wordpress/plugins'; +import { createRoot, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { ReactComponent as icon } from '../../../../assets/img/block-icon.svg'; + +window._wpLoadBlockEditor.then( async () => { + let headerSettingsPanel = null; + + await new Promise( ( resolve ) => { + const intervalId = setInterval( () => { + headerSettingsPanel = document.querySelector( '.editor-header__settings' ); + + if ( headerSettingsPanel ) { + clearInterval( intervalId ); + resolve(); + } + }, 500 ); + } ); + + if ( ! headerSettingsPanel ) { + return; + } + + const classifaiHeaderSettingButton = document.createElement( 'div' ); + classifaiHeaderSettingButton.classList.add( 'classifai-editor-header-setting-wrapper' ); + headerSettingsPanel.insertBefore( classifaiHeaderSettingButton, headerSettingsPanel.firstChild ); + + const wrapperRoot = createRoot( classifaiHeaderSettingButton ); + wrapperRoot.render( ); + +} ); + +function RenderClassifAIEditorHeaderPluginArea() { + const [ isOpen, setIsOpen ] = useState( false ); + + return ( + + { + isOpen && ( + setIsOpen( false ) } + onFocusOutside={ () => setIsOpen( false ) } + > +
      + + +
      +
      + ) + } + - - - { isPopupVisible && ( - setIsPopupVisible( false ) } - > - - - - -
      - - -
      -
      -
      - ) } + + + + + { isPreviewVisible && ( + setIsPreviewVisible( false ) } + > + + + + +
      + + +
      +
      +
      + ) } ); }; diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js index c96c177e2..bb30de041 100644 --- a/src/js/features/rewrite-tone/tones.js +++ b/src/js/features/rewrite-tone/tones.js @@ -3,36 +3,36 @@ import { __ } from '@wordpress/i18n'; export const tones = { emotion: { label: __( 'Emotion', 'classifai' ), - values: [ + value: [ { - key: 'happy', + value: 'happy', label: __( 'Happy', 'classifai' ), }, { - key: 'neutral', + value: 'neutral', label: __( 'Neutral', 'classifai' ), }, { - key: 'sad', + value: 'sad', label: __( 'Sad', 'classifai' ), }, ], }, formality: { label: __( 'Formality', 'classifai' ), - values: [ + value: [ { - key: 'formal', + value: 'formal', label: __( 'Formal', 'classifai' ), description: __( 'Professional, structured, business-like.', 'classifai' ), }, { - key: 'informal', + value: 'informal', label: __( 'Informal', 'classifai' ), description: __( 'Conversational, relaxed.', 'classifai' ), }, { - key: 'supportive', + value: 'supportive', label: __( 'Supportive', 'classifai' ), description: __( 'Reassuring, helpful.', 'classifai' ), }, @@ -42,17 +42,17 @@ export const tones = { label: __( 'Intent', 'classifai' ), value: [ { - key: 'dramatic', + value: 'dramatic', label: __( 'Dramatic', 'classifai' ), description: __( 'Intense, theatrical.', 'classifai' ), }, { - key: 'persuasive', + value: 'persuasive', label: __( 'Persuasive', 'classifai' ), description: __( 'Convincing, compelling.', 'classifai' ), }, { - key: 'storytelling', + value: 'storytelling', label: __( 'Storytelling', 'classifai' ), description: __( 'Engaging, immersive.', 'classifai' ), }, @@ -62,22 +62,22 @@ export const tones = { label: __( 'Audience', 'classifai' ), value: [ { - key: 'educational', + value: 'educational', label: __( 'Educational', 'classifai' ), description: __( 'Clear, instructive.', 'classifai' ), }, { - key: 'general', + value: 'general', label: __( 'General Audience', 'classifai' ), description: __( 'Balanced, universally understandable.', 'classifai' ), }, { - key: 'maketing', + value: 'maketing', label: __( 'Marketing & Sales', 'classifai' ), description: __( 'Promotional, action-driven.', 'classifai' ), }, { - key: 'professional', + value: 'professional', label: __( 'Business & Professional', 'classifai' ), description: __( 'Corporate, industry-focused.', 'classifai' ), }, diff --git a/src/js/features/slot-fill/editor-header-plugin-area.js b/src/js/features/slot-fill/editor-header-plugin-area.js index acb27dbd9..1f98e1781 100644 --- a/src/js/features/slot-fill/editor-header-plugin-area.js +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -55,8 +55,9 @@ function RenderClassifAIEditorHeaderPluginArea() { setIsOpen( false ) } onFocusOutside={ () => setIsOpen( false ) } + placement="top-end" > -
      +
      From b9f5405484b7548b1d63c5b579f336aabfc82ae1 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 01:29:57 +0530 Subject: [PATCH 17/31] render tone descriptions --- src/js/features/rewrite-tone/index.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index 993483508..2921932ad 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -219,6 +219,7 @@ const RewriteTonePlugin = () => { isBlock label={ tones.formality.label } value={ formality } + help={ tones.formality.value.find( el => el.value === formality )?.description } onChange={ ( newFormality ) => { setFormality( newFormality ); defaultToneAttribute( 'formality', newFormality ); @@ -231,6 +232,7 @@ const RewriteTonePlugin = () => { isBlock label={ tones.intent.label } value={ intent } + help={ tones.intent.value.find( el => el.value === intent )?.description } onChange={ ( newIntent ) => { setIntent( newIntent ); defaultToneAttribute( 'intent', newIntent ); @@ -243,6 +245,7 @@ const RewriteTonePlugin = () => { label={ tones.audience.label } options={ tones.audience.value } value={ audience } + help={ tones.audience.value.find( el => el.value === audience )?.description } onChange={ ( newAudience ) => { setAudience( newAudience ); defaultToneAttribute( 'audience', newAudience ); From 5ffc7c7b0bac530758e1a719421484b9884140ab Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 02:06:19 +0530 Subject: [PATCH 18/31] add styling to the previewer controls --- src/js/features/rewrite-tone/index.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/js/features/rewrite-tone/index.js b/src/js/features/rewrite-tone/index.js index 2921932ad..8be5d2a3e 100644 --- a/src/js/features/rewrite-tone/index.js +++ b/src/js/features/rewrite-tone/index.js @@ -110,6 +110,10 @@ const RewriteTonePlugin = () => { body: JSON.stringify( { id: wp.data.select( editorStore ).getCurrentPostId(), content: filteredBlocks, + emotion, + formality, + intent, + audience, } ), } ); @@ -282,7 +286,14 @@ const RewriteTonePlugin = () => { > -
      +
      From ac412e15de5edd89f2c84f7b11a2d0d7053c1f44 Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 02:06:37 +0530 Subject: [PATCH 19/31] handle tone attributes server-side --- includes/Classifai/Features/RewriteTone.php | 14 ++++------ .../Classifai/Providers/OpenAI/ChatGPT.php | 26 ++++++++----------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/includes/Classifai/Features/RewriteTone.php b/includes/Classifai/Features/RewriteTone.php index 9cbc733a6..e9fb6b2eb 100644 --- a/includes/Classifai/Features/RewriteTone.php +++ b/includes/Classifai/Features/RewriteTone.php @@ -22,13 +22,6 @@ class RewriteTone extends Feature { */ const ID = 'feature_rewrite_tone'; - /** - * Prompt for rewriting tone. - * - * @var string - */ - public $prompt = 'You are modifying the tone and lingo of the following text to Renaissance English.'; - /** * Constructor. */ @@ -162,7 +155,11 @@ public function rest_endpoint_callback( WP_REST_Request $request ) { $request->get_param( 'id' ), 'rewrite_tone', [ - 'content' => $request->get_param( 'content' ), + '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' ), ] ) ); @@ -233,7 +230,6 @@ public function get_feature_default_settings(): array { 'rewrite_tone_prompt' => [ [ 'title' => esc_html__( 'ClassifAI default', 'classifai' ), - 'prompt' => $this->prompt, 'original' => 1, ], ], diff --git a/includes/Classifai/Providers/OpenAI/ChatGPT.php b/includes/Classifai/Providers/OpenAI/ChatGPT.php index a1fc4a821..3da0696d8 100644 --- a/includes/Classifai/Providers/OpenAI/ChatGPT.php +++ b/includes/Classifai/Providers/OpenAI/ChatGPT.php @@ -227,6 +227,7 @@ public function rest_endpoint_callback( $post_id = 0, string $route_to_call = '' break; case 'rewrite_tone': $return = $this->rewrite_tone( $post_id, $args ); + break; case 'key_takeaways': $return = $this->generate_key_takeaways( $post_id, $args ); break; @@ -883,21 +884,12 @@ 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() ); - $prompt = esc_textarea( get_default_prompt( $settings['rewrite_tone_prompt'] ) ?? $feature->prompt ); - /** - * Filter the prompt we will send to ChatGPT. - * - * @since x.x.x - * @hook classifai_chatgpt_rewrite_tone_prompt - * - * @param {string} $prompt Prompt we are sending to ChatGPT. Gets added before post content. - * @param {int} $post_id ID of post we are summarizing. - * @param {array} $args Arguments passed to endpoint. - * - * @return {string} Prompt. - */ - $prompt = apply_filters( 'classifai_chatgpt_rewrite_tone_prompt', $prompt, $post_id, $args ); + $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', @@ -910,7 +902,11 @@ public function rewrite_tone( int $post_id, array $args = [] ) { ], [ 'role' => 'system', - 'content' => "Please return each modified content with its corresponding 'clientId'.", + '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', From 465ceee3faa95960235adf20f465e387695f48af Mon Sep 17 00:00:00 2001 From: Siddharth Thevaril Date: Mon, 3 Mar 2025 03:12:36 +0530 Subject: [PATCH 20/31] implement custom popover --- .../slot-fill/editor-header-plugin-area.js | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/src/js/features/slot-fill/editor-header-plugin-area.js b/src/js/features/slot-fill/editor-header-plugin-area.js index 1f98e1781..80a84f025 100644 --- a/src/js/features/slot-fill/editor-header-plugin-area.js +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -38,6 +38,7 @@ window._wpLoadBlockEditor.then( async () => { const classifaiHeaderSettingButton = document.createElement( 'div' ); classifaiHeaderSettingButton.classList.add( 'classifai-editor-header-setting-wrapper' ); + classifaiHeaderSettingButton.style.position = 'relative'; headerSettingsPanel.insertBefore( classifaiHeaderSettingButton, headerSettingsPanel.firstChild ); const wrapperRoot = createRoot( classifaiHeaderSettingButton ); @@ -48,22 +49,34 @@ window._wpLoadBlockEditor.then( async () => { function RenderClassifAIEditorHeaderPluginArea() { const [ isOpen, setIsOpen ] = useState( false ); + const popoverStyles = { + position: 'absolute', + top: 'calc(100% + 4px)', + right: 'calc(100% - 20px)', + boxShadow: '0 4px 5px #0000000a,0 12px 12px #00000008,0 16px 16px #00000005', + borderRadius: '4px', + transform: 'scale(1)', + transition: 'transform 0.2s ease', + 'transform-origin': 'top right' + } + + if ( ! isOpen ) { + popoverStyles.opacity = 0; + popoverStyles.pointerEvents = 'none'; + popoverStyles.transform = 'scale(0)'; + popoverStyles.transition = 'transform 0.2s ease'; + popoverStyles.zIndex = -10; + } + return ( - { - isOpen && ( - setIsOpen( false ) } - onFocusOutside={ () => setIsOpen( false ) } - placement="top-end" - > -
      - - -
      -
      - ) - } + {/* ✋🏻🛑⛔️ Don't use the Gutenberg's Popover component here. When clicked outside, it unmounts and breaks any ongoing + ClassifAI related processes, since we're rendering inside the Popover here. + This is why we implemented a custom popover which hides in plan sight, instead of unmounting. */} +
      + + +
      diff --git a/src/js/features/rewrite-tone/tones.js b/src/js/features/rewrite-tone/tones.js index bb30de041..93ef7d6ea 100644 --- a/src/js/features/rewrite-tone/tones.js +++ b/src/js/features/rewrite-tone/tones.js @@ -24,7 +24,10 @@ export const tones = { { value: 'formal', label: __( 'Formal', 'classifai' ), - description: __( 'Professional, structured, business-like.', 'classifai' ), + description: __( + 'Professional, structured, business-like.', + 'classifai' + ), }, { value: 'informal', @@ -69,7 +72,10 @@ export const tones = { { value: 'general', label: __( 'General Audience', 'classifai' ), - description: __( 'Balanced, universally understandable.', 'classifai' ), + description: __( + 'Balanced, universally understandable.', + 'classifai' + ), }, { value: 'maketing', @@ -82,5 +88,5 @@ export const tones = { description: __( 'Corporate, industry-focused.', 'classifai' ), }, ], - } -}; \ No newline at end of file + }, +}; diff --git a/src/js/features/slot-fill/editor-header-plugin-area.js b/src/js/features/slot-fill/editor-header-plugin-area.js index 80a84f025..13d642284 100644 --- a/src/js/features/slot-fill/editor-header-plugin-area.js +++ b/src/js/features/slot-fill/editor-header-plugin-area.js @@ -1,17 +1,9 @@ /** * External dependencies */ -import { - SlotFillProvider, - Slot, - Fill, - Button, - Popover, - -} from '@wordpress/components'; +import { SlotFillProvider, Slot, Fill, Button } from '@wordpress/components'; import { PluginArea } from '@wordpress/plugins'; import { createRoot, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; /** * Internal dependencies @@ -23,7 +15,9 @@ window._wpLoadBlockEditor.then( async () => { await new Promise( ( resolve ) => { const intervalId = setInterval( () => { - headerSettingsPanel = document.querySelector( '.editor-header__settings' ); + headerSettingsPanel = document.querySelector( + '.editor-header__settings' + ); if ( headerSettingsPanel ) { clearInterval( intervalId ); @@ -37,13 +31,17 @@ window._wpLoadBlockEditor.then( async () => { } const classifaiHeaderSettingButton = document.createElement( 'div' ); - classifaiHeaderSettingButton.classList.add( 'classifai-editor-header-setting-wrapper' ); + classifaiHeaderSettingButton.classList.add( + 'classifai-editor-header-setting-wrapper' + ); classifaiHeaderSettingButton.style.position = 'relative'; - headerSettingsPanel.insertBefore( classifaiHeaderSettingButton, headerSettingsPanel.firstChild ); + headerSettingsPanel.insertBefore( + classifaiHeaderSettingButton, + headerSettingsPanel.firstChild + ); const wrapperRoot = createRoot( classifaiHeaderSettingButton ); wrapperRoot.render( ); - } ); function RenderClassifAIEditorHeaderPluginArea() { @@ -53,12 +51,13 @@ function RenderClassifAIEditorHeaderPluginArea() { position: 'absolute', top: 'calc(100% + 4px)', right: 'calc(100% - 20px)', - boxShadow: '0 4px 5px #0000000a,0 12px 12px #00000008,0 16px 16px #00000005', + boxShadow: + '0 4px 5px #0000000a,0 12px 12px #00000008,0 16px 16px #00000005', borderRadius: '4px', transform: 'scale(1)', transition: 'transform 0.2s ease', - 'transform-origin': 'top right' - } + 'transform-origin': 'top right', + }; if ( ! isOpen ) { popoverStyles.opacity = 0; @@ -70,21 +69,23 @@ function RenderClassifAIEditorHeaderPluginArea() { return ( - {/* ✋🏻🛑⛔️ Don't use the Gutenberg's Popover component here. When clicked outside, it unmounts and breaks any ongoing + { /* ✋🏻🛑⛔️ Don't use the Gutenberg's Popover component here. When clicked outside, it unmounts and breaks any ongoing ClassifAI related processes, since we're rendering inside the Popover here. - This is why we implemented a custom popover which hides in plan sight, instead of unmounting. */} -
      + This is why we implemented a custom popover which hides in plan sight, instead of unmounting. */ } +
      - + + +
      + ); +}; diff --git a/src/js/components/chat-ui/chat-history.tsx b/src/js/components/chat-ui/chat-history.tsx new file mode 100644 index 000000000..52fe79fa2 --- /dev/null +++ b/src/js/components/chat-ui/chat-history.tsx @@ -0,0 +1,57 @@ +import React, { CSSProperties } from 'react'; +import { ConversationItem } from './conversation-item'; +import { ConversationEntry } from './types'; + +// Define style object outside of JSX +const historyContainerStyles: CSSProperties = { + marginBottom: '10px', + maxHeight: '400px', + overflowY: 'auto', + padding: '10px', + borderRadius: '8px', + border: '1px solid #ccc', + flex: '1', +}; + +/** + * Props for the ChatHistory component + */ +export interface ChatHistoryProps { + conversation: ConversationEntry[]; + onStartOver: () => void; + onInsertContent: ( content: string ) => void; +} + +/** + * ChatHistory component + * + * Displays the conversation history between the user and AI + * + * @param {ChatHistoryProps} props Component props + * @return {React.ReactElement|null} Chat history container or null if no conversation + */ +export const ChatHistory: React.FC< ChatHistoryProps > = ( { + conversation, + onStartOver, + onInsertContent, +} ) => { + if ( conversation.length === 0 ) { + return null; + } + + return ( +
      + { conversation.map( ( entry, index ) => ( + + ) ) } +
      + ); +}; diff --git a/src/js/components/chat-ui/chat-input.tsx b/src/js/components/chat-ui/chat-input.tsx new file mode 100644 index 000000000..fa5453209 --- /dev/null +++ b/src/js/components/chat-ui/chat-input.tsx @@ -0,0 +1,94 @@ +import React, { CSSProperties } from 'react'; +import { TextareaControl, Button, Icon } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { keyboardReturn } from '@wordpress/icons'; + +// Define style objects outside of JSX +const containerStyles: CSSProperties = { + position: 'relative', +}; + +const getTextareaStyles = ( isLoading: boolean ): CSSProperties => ( { + width: '100%', + height: '80px', + maxHeight: '200px', + minHeight: '100px', + borderRadius: '4px', + border: '1px solid #ccc', + padding: '10px 75px 10px 10px', + resize: 'none', + opacity: isLoading ? 0.7 : 1, +} ); + +const buttonStyles: CSSProperties = { + position: 'absolute', + bottom: '8px', + right: '8px', + paddingInline: '8px', + paddingInlineStart: '6px', +}; + +/** + * Props for the ChatInput component + */ +export interface ChatInputProps { + value: string; + onChange: ( value: string ) => void; + onKeyDown: ( event: React.KeyboardEvent< HTMLTextAreaElement > ) => void; + isLoading: boolean; + placeholderText: string; + textareaRef: React.RefObject< HTMLTextAreaElement >; +} + +/** + * ChatInput component + * + * Input area for user to type messages to the AI + * + * @param {ChatInputProps} props Component props + * @return {React.ReactElement} Chat input component + */ +export const ChatInput: React.FC< ChatInputProps > = ( { + value, + onChange, + onKeyDown, + isLoading, + placeholderText, + textareaRef, +} ) => { + return ( +
      + + +
      + ); +}; diff --git a/src/js/components/chat-ui/chat-ui.tsx b/src/js/components/chat-ui/chat-ui.tsx new file mode 100644 index 000000000..eb5dc4564 --- /dev/null +++ b/src/js/components/chat-ui/chat-ui.tsx @@ -0,0 +1,458 @@ +import React, { + useEffect, + useState, + useRef, + useLayoutEffect, + CSSProperties, +} from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import apiFetch from '@wordpress/api-fetch'; +import { select, dispatch } from '@wordpress/data'; +import { pasteHandler, parse } from '@wordpress/blocks'; +import { store as editorStore } from '@wordpress/editor'; +import { store as blockEditorStore } from '@wordpress/block-editor'; +import { __ } from '@wordpress/i18n'; +import { decodeEntities } from '@wordpress/html-entities'; + +// Import our custom components +import { SparkleIcon } from './sparkle-icon'; +import { ChatHistory } from './chat-history'; +import { QuickActionOptions } from './quick-action-options'; +import { ErrorMessage } from './error-message'; +import { ChatInput } from './chat-input'; +import { ConversationEntry } from './types'; + +// Define style objects outside of JSX +const chatContainerStyles: CSSProperties = { + position: 'absolute', + bottom: '20px', + right: '20px', + zIndex: '1000', +}; + +const chatUIStyles: CSSProperties = { + width: '500px', + maxHeight: '700px', + backgroundColor: 'white', + padding: '14px', + boxShadow: + '0px 2px 3px 0px rgba(0, 0, 0, 0.05), 0px 4px 5px 0px rgba(0, 0, 0, 0.04), 0px 4px 5px 0px rgba(0, 0, 0, 0.03), 0px 16px 16px 0px rgba(0, 0, 0, 0.02)', + borderRadius: '8px', + border: '1px solid #e0e0e0', + display: 'flex', + flexDirection: 'column', +}; + +const chatContentStyles: CSSProperties = { + display: 'flex', + flexDirection: 'column', + height: '100%', + maxHeight: '700px', + overflow: 'clip', + padding: '2px', +}; + +const chatTitleStyles: CSSProperties = { + marginBottom: '12px', + fontWeight: 'bold', + fontSize: '16px', +}; + +const chatButtonStyles: CSSProperties = { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + boxShadow: '0 0 10px rgba(0, 0, 0, 0.1)', + padding: '0', + width: '48px', + height: '48px', + borderRadius: '999px', + minWidth: 'unset', + minHeight: 'unset', + color: 'white', + border: 'none', + cursor: 'pointer', + backgroundColor: + 'var(--wp-components-color-accent-darker-10,var(--wp-admin-theme-color-darker-10,#2145e6))', +}; + +/** + * ChatUI component + * + * Main component for the ClassifAI chat interface + * + * @return {React.ReactElement} The complete chat UI + */ +export const ChatUI: React.FC = () => { + const [ inputValue, setInputValue ] = useState< string >( '' ); + const [ isExpanded, setIsExpanded ] = useState< boolean >( false ); + const [ isLoading, setIsLoading ] = useState< boolean >( false ); + const [ error, setError ] = useState< string | false >( false ); + const [ conversation, setConversation ] = useState< ConversationEntry[] >( + [] + ); + const chatContainerRef = useRef< HTMLDivElement >( null ); + const textareaRef = useRef< HTMLTextAreaElement >( null ); + + // Function to handle clicks outside the chat UI + const handleClickOutside = ( event: MouseEvent ): void => { + if ( + chatContainerRef.current && + ! chatContainerRef.current.contains( event.target as Node ) + ) { + setIsExpanded( false ); + } + }; + + useLayoutEffect( () => { + if ( textareaRef.current && isExpanded ) { + textareaRef.current.focus(); + } + }, [ isExpanded ] ); + + // Add event listeners for clicks outside + useEffect( () => { + // Only add event listeners when the chat UI is expanded + if ( isExpanded ) { + // Add event listener to main document + document.addEventListener( 'mousedown', handleClickOutside ); + + // Add event listeners to all iframes + const iframes = document.querySelectorAll( 'iframe' ); + iframes.forEach( ( iframe ) => { + try { + if ( iframe.contentDocument ) { + iframe.contentDocument.addEventListener( + 'mousedown', + handleClickOutside + ); + } + } catch ( e ) { + // Cross-origin iframe access error - can't add listener + // Silently fail for cross-origin iframes + } + } ); + } + + return () => { + // Remove event listener from main document + document.removeEventListener( 'mousedown', handleClickOutside ); + + // Remove event listeners from all iframes + const iframes = document.querySelectorAll( 'iframe' ); + iframes.forEach( ( iframe ) => { + try { + if ( iframe.contentDocument ) { + iframe.contentDocument.removeEventListener( + 'mousedown', + handleClickOutside + ); + } + } catch ( e ) { + // Cross-origin iframe access error - can't remove listener + // Silently fail for cross-origin iframes + } + } ); + }; + }, [ isExpanded ] ); + + // Handle quick action option selection + // TODO: Look to fully support this in the future. + const handleOptionSelect = ( option: string ): void => { + let prompt = ''; + const selectedContent = select( editorStore ).getEditedPostContent(); + + switch ( option ) { + case 'proofread': + prompt = `Proofread the following content and correct any grammar, spelling, or punctuation errors:\n\n${ selectedContent }`; + break; + case 'rewrite': + // If rewrite is clicked from the initial view, we handle it through the QuickActionOptions component + // which will expand and show all options rather than starting a conversation + return; + case 'rewrite-execute': + // This is when rewrite is clicked from the expanded options view + prompt = `Rewrite the following content to improve clarity and flow:\n\n${ selectedContent }`; + break; + case 'tone-friendly': + prompt = `Rewrite the following content using a friendly, conversational tone:\n\n${ selectedContent }`; + break; + case 'tone-professional': + prompt = `Rewrite the following content using a professional, formal tone:\n\n${ selectedContent }`; + break; + case 'tone-concise': + prompt = `Rewrite the following content to be more concise and direct:\n\n${ selectedContent }`; + break; + case 'summary': + prompt = `Create a summary of the following content:\n\n${ selectedContent }`; + break; + case 'key-points': + prompt = `Extract the key points from the following content:\n\n${ selectedContent }`; + break; + case 'table': + prompt = `Convert the following content into a well-structured table:\n\n${ selectedContent }`; + break; + case 'list': + prompt = `Convert the following content into a bulleted list:\n\n${ selectedContent }`; + break; + case 'compose': + prompt = `Write a blog post about: `; + setInputValue( prompt ); + return; + case 'custom': + // Just focus the input field + return; + default: + return; + } + + // Auto-submit the prompt + setInputValue( '' ); + + // Get post data + const postId = select( editorStore ).getCurrentPostId(); + const title = select( editorStore ).getEditedPostAttribute( 'title' ); + + // Update conversation immediately with user message + const updatedConversation: ConversationEntry[] = [ + ...conversation, + { + prompt, + completion: null, // Will be filled in once API response is received + }, + ]; + setConversation( updatedConversation ); + + // Call API + setIsLoading( true ); + apiFetch( { + path: '/classifai/v1/create-content', + method: 'POST', + data: { + id: postId, + summary: prompt, + title, + conversation: updatedConversation.slice( 0, -1 ), + }, + } ).then( + ( res: unknown ) => { + // Update conversation with response + setConversation( [ + ...updatedConversation.slice( 0, -1 ), + { + prompt, + completion: res as string, + }, + ] ); + setError( false ); + setIsLoading( false ); + }, + ( err: { message?: string } ) => { + setError( err?.message || 'An error occurred' ); + setIsLoading( false ); + } + ); + }; + + const handleSubmit = ( event: React.FormEvent ): void => { + event.preventDefault(); + if ( ! inputValue.trim() ) { + return; + } + + const userMessage = inputValue; + setInputValue( '' ); + setError( false ); + + // Get post data + const postId = select( editorStore ).getCurrentPostId(); + const title = select( editorStore ).getEditedPostAttribute( 'title' ); + + // Update conversation immediately with user message + const updatedConversation: ConversationEntry[] = [ + ...conversation, + { + prompt: userMessage, + completion: null, // Will be filled in once API response is received + }, + ]; + setConversation( updatedConversation ); + + // Call API + setIsLoading( true ); + apiFetch( { + path: '/classifai/v1/create-content', + method: 'POST', + data: { + id: postId, + summary: userMessage, + title, + conversation: updatedConversation.slice( 0, -1 ), // Exclude the message we just added + }, + } ).then( + ( res: unknown ) => { + // Update conversation with response + setConversation( [ + ...updatedConversation.slice( 0, -1 ), + { + prompt: userMessage, + completion: res as string, + }, + ] ); + setError( false ); + setIsLoading( false ); + }, + ( err: { message?: string } ) => { + setError( err?.message || 'An error occurred' ); + setConversation( [] ); + setIsLoading( false ); + } + ); + }; + + const handleKeyDown = ( + event: React.KeyboardEvent< HTMLTextAreaElement > + ): void => { + // Submit on Enter key, but not when Shift is pressed + if ( event.key === 'Enter' && ! event.shiftKey ) { + event.preventDefault(); + handleSubmit( event as unknown as React.FormEvent ); + } + // Shift+Enter will add a new line by default (no action needed) + }; + + const toggleChatUI = (): void => { + setIsExpanded( ! isExpanded ); + }; + + const startOver = (): void => { + setConversation( [] ); + setError( false ); + }; + + const insertContent = ( content: string ): void => { + dispatch( editorStore ) + .editPost( { + content: '', + } ) + .then( () => { + const contentWithEntities = decodeEntities( content ); + + const containsBlockMarkup = + contentWithEntities.includes( '