/* * External dependencies */ import { ERROR_NETWORK, ERROR_QUOTA_EXCEEDED, useAiSuggestions, } from '@automattic/jetpack-ai-client'; import { BlockControls, useBlockProps } from '@wordpress/block-editor'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useDispatch, useSelect } from '@wordpress/data'; import { useCallback, useEffect, useState, useRef, useMemo } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; import debugFactory from 'debug'; import React from 'react'; /* * Internal dependencies */ import useAiFeature from '../hooks/use-ai-feature'; import useAutoScroll from '../hooks/use-auto-scroll'; import { mapInternalPromptTypeToBackendPromptType } from '../lib/prompt/backend-prompt'; import AiAssistantInput from './components/ai-assistant-input'; import AiAssistantExtensionToolbarDropdown from './components/ai-assistant-toolbar-dropdown'; import { getBlockHandler, InlineExtensionsContext } from './get-block-handler'; import { isPossibleToExtendBlock } from './lib/is-possible-to-extend-block'; /* * Types */ import type { AiAssistantDropdownOnChangeOptionsArgProps, OnRequestSuggestion, } from '../components/ai-assistant-toolbar-dropdown/dropdown-content'; import type { ExtendedInlineBlockProp } from '../extensions/ai-assistant'; import type { PromptTypeProp } from '../lib/prompt'; import type { PromptMessagesProp, PromptItemProps } from '@automattic/jetpack-ai-client'; const debug = debugFactory( 'jetpack-ai-assistant:extensions:with-ai-extension' ); const blockExtensionMapper = { 'core/heading': 'heading', 'core/paragraph': 'paragraph', 'core/list-item': 'list-item', 'core/list': 'list', }; // Defines where the block controls should be placed in the toolbar const blockControlsProps = { group: 'block' as const, }; const BLOCK_INPUT_GAP = 16; type RequestOptions = { promptType: PromptTypeProp; options?: AiAssistantDropdownOnChangeOptionsArgProps; humanText?: string; message?: PromptItemProps; }; type CoreEditorDispatch = { undo: () => Promise< void > }; type CoreEditorSelect = { getCurrentPostId: () => number }; // HOC to populate the block's edit component with the AI Assistant control inpuit and toolbar button. const blockEditWithAiComponents = createHigherOrderComponent( BlockEdit => { return props => { const { clientId, isSelected, name: blockName } = props; // Ref to the control wrapper, its height and its ResizeObserver, for positioning adjustments. const controlRef: React.MutableRefObject< HTMLDivElement | null > = useRef( null ); const controlHeight = useRef< number >( 0 ); const controlObserver = useRef< ResizeObserver | null >( null ); // Ref to the original block padding to reset it when the AI Control is closed. const blockOriginalPaddingBottom = useRef< string >( '' ); // Ref to the input element to focus on it when the AI Control is displayed or when a request is done. // Also used to determine the ownerDocument, as the editor can be in an iframe. const inputRef: React.MutableRefObject< HTMLInputElement | null > = useRef( null ); const ownerDocument = useRef< Document >( document ); // Ref to the chat history to keep track of the messages that were sent and the assistant responses. const chatHistory = useRef< PromptMessagesProp >( [] ); // A human-readable action to be displayed in the input when a toolbar suggestion is requested, like "Translate: Japanese". const [ action, setAction ] = useState< string >( '' ); // The last request made by the user, to be used when the user clicks the "Try Again" button. const lastRequest = useRef< RequestOptions | null >( null ); // State to display the AI Control or not. const [ showAiControl, setShowAiControl ] = useState( false ); // Data and functions from the editor. const { undo } = useDispatch( 'core/editor' ) as CoreEditorDispatch; const { postId } = useSelect( select => { const { getCurrentPostId } = select( 'core/editor' ) as CoreEditorSelect; return { postId: getCurrentPostId() }; }, [] ); // The block's id to find it in the DOM for the positioning adjustments // The classname is used by nested blocks to determine which block's toolbar to display when the input is focused. const { id, className } = useBlockProps(); // Jetpack AI Assistant feature functions. const { increaseRequestsCount, dequeueAsyncRequest, requireUpgrade } = useAiFeature(); // Auto-scroll const { snapToBottom, enableAutoScroll, disableAutoScroll } = useAutoScroll( { current: ownerDocument?.current?.getElementById( id ), }, undefined, true ); const focusInput = useCallback( () => { inputRef.current?.focus(); }, [] ); // Data and functions with block-specific implementations. const { onSuggestion: onBlockSuggestion, onDone: onBlockDone, getContent, behavior, isChildBlock, } = useMemo( () => getBlockHandler( blockName, clientId ), [ blockName, clientId ] ); // Called when the user clicks the "Ask AI Assistant" button. const handleAskAiAssistant = useCallback( () => { setShowAiControl( current => ! current ); }, [] ); // Function to get the messages array for the request. const getRequestMessages = useCallback( ( { promptType, options, }: { promptType: PromptTypeProp; options?: AiAssistantDropdownOnChangeOptionsArgProps; } ) => { const blockContent = getContent(); const extension = blockExtensionMapper[ blockName ]; return [ ...chatHistory.current, { role: 'jetpack-ai' as const, context: { type: mapInternalPromptTypeToBackendPromptType( promptType, extension ), content: blockContent, request: options?.userPrompt, tone: options?.tone, language: options?.language, is_follow_up: chatHistory.current.length > 0, }, }, ]; }, [ blockName, getContent ] ); const adjustBlockPadding = useCallback( ( blockElement?: HTMLElement | null ) => { const block = blockElement || ownerDocument.current.getElementById( id ); if ( block && controlRef.current ) { // The gap between the input and the block's bottom is set at BLOCK_INPUT_GAP, regardless of the theme block.style.setProperty( 'padding-bottom', `calc(${ controlHeight.current + BLOCK_INPUT_GAP }px + ${ blockOriginalPaddingBottom.current || '0px' } )`, 'important' ); } }, [ id ] ); // Called when a suggestion chunk is received. const onSuggestion = useCallback( ( suggestion: string ) => { onBlockSuggestion( suggestion ); // Make sure the block element has the necessary bottom padding, as it can be replaced or changed adjustBlockPadding(); // Scroll to the bottom when a new suggestion is received. snapToBottom(); }, [ onBlockSuggestion, adjustBlockPadding, snapToBottom ] ); // Called after the last suggestion chunk is received. const onDone = useCallback( ( suggestion: string ) => { disableAutoScroll(); onBlockDone( suggestion ); increaseRequestsCount(); setAction( '' ); if ( lastRequest.current?.message ) { const assistantMessage = { role: 'assistant' as const, content: getContent(), }; chatHistory.current.push( lastRequest.current.message, assistantMessage ); // Limit the messages to 20 items. if ( chatHistory.current.length > 20 ) { chatHistory.current.splice( 0, chatHistory.current.length - 20 ); // Make sure the first message is a 'jetpack-ai' message and not marked as a follow-up. const firstJetpackAiMessageIndex = chatHistory.current.findIndex( message => message.role === 'jetpack-ai' ); if ( firstJetpackAiMessageIndex !== -1 ) { chatHistory.current = chatHistory.current.slice( firstJetpackAiMessageIndex ); chatHistory.current[ 0 ].context = { ...chatHistory.current[ 0 ].context, is_follow_up: false, }; } } } lastRequest.current = null; // Make sure the block element has the necessary bottom padding, as it can be replaced or changed setTimeout( () => { adjustBlockPadding(); focusInput(); }, 100 ); }, [ disableAutoScroll, onBlockDone, increaseRequestsCount, getContent, adjustBlockPadding, focusInput, ] ); // Called when an error is received. const onError = useCallback( error => { disableAutoScroll(); setAction( '' ); debug( 'Request error', error ); // Increase the AI Suggestion counter only for valid errors. if ( error.code === ERROR_NETWORK || error.code === ERROR_QUOTA_EXCEEDED ) { return; } increaseRequestsCount(); }, [ disableAutoScroll, increaseRequestsCount ] ); const { request, stopSuggestion, requestingState, error, reset: resetSuggestions, } = useAiSuggestions( { onSuggestion, onDone, onError, askQuestionOptions: { postId, feature: 'ai-assistant', }, } ); // Called when a suggestion from the toolbar is requested, like "Change tone". const handleRequestSuggestion = useCallback< OnRequestSuggestion >( ( promptType, options, humanText ) => { setShowAiControl( true ); // If the user needs to upgrade, don't make the request, but show the input with the upgrade message. if ( requireUpgrade ) { return; } if ( humanText ) { setAction( humanText ); } const messages = getRequestMessages( { promptType, options } ); debug( 'Request suggestion', promptType, options ); const lastMessage = messages[ messages.length - 1 ]; lastRequest.current = { promptType, options, humanText, message: lastMessage }; /* * Always dequeue/cancel the AI Assistant feature async request, * in case there is one pending, * when performing a new AI suggestion request. */ dequeueAsyncRequest(); enableAutoScroll(); request( messages ); }, [ dequeueAsyncRequest, enableAutoScroll, getRequestMessages, request, requireUpgrade ] ); // Called when the user types a custom prompt. const handleUserRequest = useCallback( ( userPrompt: string ) => { const promptType = 'userPrompt'; const options = { userPrompt }; enableAutoScroll(); handleRequestSuggestion( promptType, options ); }, [ enableAutoScroll, handleRequestSuggestion ] ); // Called when the user clicks the "Stop" button in the input. const handleStopSuggestion = useCallback( () => { disableAutoScroll(); stopSuggestion(); focusInput(); }, [ disableAutoScroll, stopSuggestion, focusInput ] ); // Called when the user clicks the "Try Again" button in the input error message. const handleTryAgain = useCallback( () => { if ( lastRequest.current ) { handleRequestSuggestion( lastRequest.current.promptType, lastRequest.current.options, lastRequest.current.humanText ); } }, [ lastRequest, handleRequestSuggestion ] ); // Cleanup function. const handleClose = useCallback( () => { setShowAiControl( false ); resetSuggestions(); setAction( '' ); lastRequest.current = null; chatHistory.current = []; }, [ resetSuggestions ] ); // Called when the user clicks the "Undo" button after a successful request. const handleUndo = useCallback( async () => { await undo(); handleClose(); }, [ undo, handleClose ] ); // Closes the AI Control if the block is deselected. useEffect( () => { if ( ! isSelected ) { handleClose(); } }, [ isSelected, handleClose ] ); // Focus the input when the AI Control is displayed and set the ownerDocument. useEffect( () => { if ( inputRef.current ) { // Save the block's ownerDocument to use it later, as the editor can be in an iframe. ownerDocument.current = inputRef.current.ownerDocument; // Focus the input when the AI Control is displayed. focusInput(); } }, [ showAiControl, focusInput ] ); // Adjusts the input position in the editor by increasing the block's bottom-padding // and setting the control's margin-top, "wrapping" the input with the block. useEffect( () => { let block = ownerDocument.current.getElementById( id ); if ( ! block ) { return; } // Once when the AI Control is displayed if ( showAiControl && ! controlObserver.current && controlRef.current ) { // Save the block bottom padding to reset it later. blockOriginalPaddingBottom.current = block.style.paddingBottom; // Observe the control's height to adjust the block's bottom padding. controlObserver.current = new ResizeObserver( ( [ entry ] ) => { // The block element can be replaced or changed, so we need to get it again. block = ownerDocument.current.getElementById( id ); controlHeight.current = entry.contentRect.height; if ( block && controlRef.current && controlHeight.current > 0 ) { adjustBlockPadding( block ); const { marginBottom } = getComputedStyle( block ); const bottom = parseFloat( marginBottom ); // The control's margin-top is the negative of the control's height plus the block's bottom margin, to end up with the intended gap. // P2 uses "!important", so we need to add it to override the theme's styles. controlRef.current.style.setProperty( 'margin-top', `-${ controlHeight.current + bottom }px`, 'important' ); // The control's bottom margin is set to at least the same value as the block's bottom margin, to keep the distance to the next block. // The gap height is added for a bit more space on themes with a smaller bottom margin. controlRef.current.style.setProperty( 'margin-bottom', `${ bottom + BLOCK_INPUT_GAP }px`, 'important' ); } } ); controlObserver.current.observe( controlRef.current ); } else if ( controlObserver.current ) { block.style.paddingBottom = blockOriginalPaddingBottom.current; controlObserver.current.disconnect(); controlObserver.current = null; controlHeight.current = 0; } return () => { if ( controlObserver.current ) { controlObserver.current.disconnect(); } }; }, [ adjustBlockPadding, clientId, controlObserver, id, showAiControl ] ); const aiInlineExtensionContent = ( <> { showAiControl && ( ) } ); if ( isChildBlock ) { return aiInlineExtensionContent; } const ProviderProps = { value: { [ blockName ]: { handleAskAiAssistant, handleRequestSuggestion } }, }; return ( { aiInlineExtensionContent } ); }; }, 'blockEditWithAiComponents' ); /** * Function used to extend the registerBlockType settings. * Populates the block edit component with the AI Assistant bar and button. * @param {object} settings - The block settings. * @param {string} name - The block name. * @returns {object} The extended block settings. */ function blockWithInlineExtension( settings, name: ExtendedInlineBlockProp ) { // Only extend the allowed block types and when AI is enabled const possibleToExtendBlock = isPossibleToExtendBlock( name ); if ( ! possibleToExtendBlock ) { return settings; } return { ...settings, edit: blockEditWithAiComponents( settings.edit ), }; } addFilter( 'blocks.registerBlockType', 'jetpack/ai-assistant-support/with-ai-extension', blockWithInlineExtension, 100 );